Compare commits

...

29 Commits

Author SHA1 Message Date
6ddff5583d fix(ORCH-058): parametrize staging_check in --build-staging + explicit staging target
All checks were successful
CI / test (push) Successful in 19s
CI / test (pull_request) Successful in 18s
Round-3 review follow-up on c53d625 (P1/P2):

- P1: --build-staging now runs staging_check via parametrized
  STAGING_CONTAINER / STAGING_CHECK_PATH / STAGING_CHECK_MODE (default
  orchestrator-staging / bind-mount path / stub) instead of hardcoding
  $TARGET_SERVICE + the script path. docker exec runs INSIDE the staging
  container (ORCH-048 canonical: B6 registry isolation), after health,
  before exit 0. Fail-closed: any non-zero -> exit 1. STAGING only (8501).
- P2a: rebuild_staging_image now passes the STAGING target EXPLICITLY
  (TARGET_SERVICE/TARGET_PORT/COMPOSE_PROFILE/STAGING_CONTAINER) so the
  self-rebuild can never drift onto prod 8500 if hook defaults change (AC-9).
- P2b: TC-09 caller<->hook contract tests assert the ssh command carries
  GIT_SHA + BUILD_CONTEXT + the staging target and never the prod 8500 one;
  no-ssh-host fails closed.
- P3: consolidated the three duplicate README footers into one.
- Docs (golden source): DEPLOY_HOOK.md step 4 + env rows, README footer,
  CHANGELOG, Dockerfile ARG GIT_SHA="" comment, .env.example freshness block.

Validates exactly the artefact later BUILD-ONCE retagged to prod (AC-4,
ADR-001 step 3). 632 tests pass, ruff clean, bash -n OK.

Refs: ORCH-058

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 09:24:38 +00:00
Dev Agent
c53d625744 ORCH-058: --build-staging runs staging_check.py --mode stub vs fresh 8501 (AC-4)
All checks were successful
CI / test (push) Successful in 16s
Per ADR-001 step 3 / AC-4: after the freshly rebuilt staging container is
healthy, run staging_check.py --mode stub against the fresh 8501 stand BEFORE
reporting success, so the EXACT artefact BUILD-ONCE retagged to prod is the one
validated on staging. Fail-closed: staging_check rc!=0 -> exit 1 (not promoted).

- Invoked inside the container (docker exec $TARGET_SERVICE) per the canonical
  signature in scripts/staging_check.py header, --base-url http://localhost:$TARGET_PORT.
- Targets ONLY 8501 (staging), never 8500 (prod) - AC-9.
- --mode stub: fast, deterministic, no LLM spend (ADR).
- Static regression test test_tc07_build_staging_runs_staging_check_stub_after_health:
  asserts staging_check.py + --mode stub present, runs after health, before exit 0,
  fail-closed, and never hard-codes prod 8500.
2026-06-07 12:11:07 +03:00
2ee06ae676 fix(deploy-hook): --build-staging must build from validated worktree, recreate+health 8501
All checks were successful
CI / test (push) Successful in 17s
Closes reviewer P0/P1 (ORCH-058 attempt 3): the committed --build-staging hook
recomputed GIT_SHA=$(git rev-parse HEAD) in $REPO (prod clone on `main`) and built
`docker build ... "$REPO"`, ignoring the caller-supplied BUILD_CONTEXT/GIT_SHA. On
the deploy-staging -> deploy edge the PR is not yet merged, so `main` HEAD != the
validated SHA -> the staging image got the wrong revision label and Strategy-B's
guard fail-closed on EVERY valid self-deploy (AC-6 deadlock). It also only did
`docker build` + exit 0 — never recreating 8501 nor health-checking — so
rebuild_staging_image's rc=0 ("rebuilt and healthy") was a lie (AC-4 unmet).

- Hook --build-staging now honours caller BUILD_CONTEXT (validated worktree) and
  GIT_SHA, recreates orchestrator-staging on the fresh image and runs the 10x6s
  health-check; build/health failure -> exit 1 (FAILED contract preserved).
- image_freshness.rebuild_staging_image: document why COMPOSE_PROFILE/TARGET_SERVICE/
  TARGET_PORT are intentionally omitted (hook STAGING defaults -> 8501 only, P2).
- tests: assert the caller<->hook contract (builds from $BUILD_CONTEXT, no
  `git rev-parse HEAD` recompute, recreates + health-checks 8501) so the P0
  regression can't pass green again (P1).

Refs: ORCH-058

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 08:37:51 +00:00
3b3d587300 docs(ORCH-058): add CHANGELOG entry, .env.example flags, fix README status
All checks were successful
CI / test (push) Successful in 17s
Close AC-11 documentation gap left by the prior developer run: the
ORCH-058 feature (staging-image provenance before BUILD-ONCE retag) was
implemented and green but never recorded in the golden-source docs.

- CHANGELOG.md: add the ORCH-058 [Unreleased]/Added entry (layers A+B,
  validated_revision anchor, check_staging_image_fresh, EXPECTED_REVISION
  hook guard, new ORCH_IMAGE_FRESHNESS_* flags, ADR/test refs).
- .env.example (canon): document ORCH_IMAGE_FRESHNESS_ENABLED /
  ORCH_IMAGE_FRESHNESS_REPOS, mirroring the ORCH-036/043/053 precedent.
- docs/architecture/README.md: footer note design -> реализовано, aligning
  it with the already-updated section.

Refs: ORCH-058

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 08:27:57 +00:00
Dev Agent
f0c2986477 ORCH-058: implement fail-closed provenance guard in deploy-hook + GIT_SHA OCI label in Dockerfile
All checks were successful
CI / test (push) Successful in 16s
- deploy-hook: REVISION_LABEL/EXPECTED_REVISION (default unset -> backward-compat)
- deploy-hook: fail-closed guard inspects SOURCE_IMAGE revision label before docker tag, normalises <no value>, exit 1 on empty/mismatch
- deploy-hook: new --build-staging mode rebuilds staging image stamping GIT_SHA
- Dockerfile: ARG GIT_SHA + LABEL org.opencontainers.image.revision=$GIT_SHA

Closes TC07/TC08 (tests/test_deploy_hook_provenance.py).
2026-06-07 11:20:38 +03:00
83397570fe developer(ET): auto-commit from developer run_id=264
Some checks failed
CI / test (push) Failing after 17s
2026-06-07 07:46:19 +00:00
dbc32fc106 architect(ET): auto-commit from architect run_id=263
All checks were successful
CI / test (push) Successful in 16s
2026-06-07 07:27:38 +00:00
282636fedb analyst(ET): auto-commit from analyst run_id=262
All checks were successful
CI / test (push) Successful in 16s
2026-06-07 07:06:10 +00:00
e5f9c38e65 docs: init ORCH-058 business request
All checks were successful
CI / test (push) Successful in 17s
2026-06-07 10:01:11 +03:00
stream
e4c6401633 docs(history): LESSONS self-deploy bootstrap — каскад 4 инфра-багов (passwd/env/log-perms/stale-staging-image)
Some checks failed
CI / test (push) Has been cancelled
2026-06-07 09:52:39 +03:00
stream
115519ebb4 fix(compose): ORCH_DEPLOY_* env for self-deploy (prefix ORCH_, orchestrator hook, host-repo path) — ORCH-36 Phase B 2026-06-07 09:39:51 +03:00
stream
64e031a37f fix(docker): passwd entry for uid 1000 (slin) — fixes ssh/whoami, unblocks ORCH-36 self-deploy Phase B 2026-06-07 09:27:04 +03:00
01ff71978f docs(ORCH-036): staging gate SUCCESS — 10/10 checks pass (re-run inside orchestrator-staging)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 21:48:50 +00:00
stream
d5915a89b9 docs(history): LESSONS ORCH-036+053 — bootstrap-деплой, merge-конфликт, reconciler в проде 2026-06-07 00:34:36 +03:00
1ff8d85bb9 Merge pull request 'feat: executable self-deploy — stage deploy triggers host hook (ORCH-036)' (#55) from feature/ORCH-036-orch-36-deploy-b into main 2026-06-07 00:23:40 +03:00
stream
36c1898fac Merge remote-tracking branch 'origin/main' into feature/ORCH-036-orch-36-deploy-b
All checks were successful
CI / test (push) Successful in 16s
CI / test (pull_request) Successful in 14s
# Conflicts:
#	.env.example
#	CHANGELOG.md
#	docs/architecture/README.md
#	docs/operations/INFRA.md
#	src/config.py
2026-06-07 00:22:19 +03:00
e2dc9d6df6 Merge pull request 'ORCH-053: sweeper потерянных webhook (реконсиляция застрявших стадий)' (#56) from feature/ORCH-053-sweeper-webhook-stuck-task into main 2026-06-07 00:20:53 +03:00
c0bcb544cf tester(ET): auto-commit from tester run_id=201
All checks were successful
CI / test (push) Successful in 17s
CI / test (pull_request) Successful in 15s
2026-06-06 21:07:35 +00:00
2be39b398b reviewer(ET): auto-commit from reviewer run_id=199 2026-06-06 21:07:35 +00:00
d79defeadd fix(deploy): clear stale self-deploy markers on rollback; document env
Re-deploy after a FAILED prod deploy wedged the task on `deploy`: the
sentinel markers (approve-requested/initiated/result) are keyed by the
stable work_item_id, so after the БАГ-8 rollback (deploy -> development)
and a developer fix, Phase B's idempotency-guard saw a STALE `initiated`
and became a no-op — the detached hook never re-launched and the
finalizer was never enqueued. Add self_deploy.clear_state (never-raise,
idempotent) and call it on the check_deploy_status FAILED rollback and at
the start of Phase A, so every fresh prod-deploy pass starts clean.

Also document the new ORCH_SELF_DEPLOY_* / ORCH_DEPLOY_* descriptors in
the canonical .env.example (CLAUDE.md rule #8, ТЗ §2.6), modelled on the
ORCH-043 merge-gate block (placeholders only, secrets not committed).

Contracts untouched: STAGE_TRANSITIONS, QG_CHECKS, _parse_deploy_status,
БАГ-8, merge-gate.

Refs: ORCH-036
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 21:07:35 +00:00
9f43e6a0ae reviewer(ET): auto-commit from reviewer run_id=195 2026-06-06 21:07:35 +00:00
10f2a39a58 feat(deploy): build-once SOURCE_IMAGE retag in hook + deploy-stage docs
Add the optional, backward-compatible SOURCE_IMAGE branch to
orchestrator-deploy-hook.sh: when set, retag the staging-validated image
onto TARGET_IMAGE (docker tag) before `up -d --no-build` instead of
rebuilding — guarantees prod runs the exact artefact that passed staging
(AC-7 / TC-14). Unset -> prior behaviour; exit-code contract (0/1/2) and
health-loop untouched.

Update golden-source docs (AC-13): rewrite deployer.md `deploy` stage from
"paper SUCCESS" to the executable self-deploy (Phase A/B/C, no self-restart
from inside the container) and add the ORCH-036 CHANGELOG entry.

Refs: ORCH-036

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 21:07:35 +00:00
63187ff102 developer(ET): auto-commit from developer run_id=192 2026-06-06 21:07:35 +00:00
5c5525548d architect(ET): auto-commit from architect run_id=190 2026-06-06 21:07:35 +00:00
0d0cd6e281 analyst(ET): auto-commit from analyst run_id=189 2026-06-06 21:07:35 +00:00
480b203a9d docs: init ORCH-036 business request 2026-06-06 21:07:35 +00:00
7705552f08 docs(ORCH-036): staging gate log — staging_status SUCCESS (10/10 PASS)
Re-run of deploy-staging gate (merge-gate defer cycle). Canonical
staging_check.py (mode=stub) ran inside orchestrator-staging (8501);
all 10 checks passed (exit 0). No prod (8500) container touched.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 21:07:20 +00:00
d43603b224 docs(ORCH-053): deploy gate log — deploy_status SUCCESS
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 21:04:04 +00:00
682ae09316 docs(ORCH-036): staging gate SUCCESS log (10/10 checks PASS)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 20:58:12 +00:00
56 changed files with 5097 additions and 11 deletions

View File

@@ -36,6 +36,54 @@ ORCH_MERGE_RETEST_TARGET=tests/
ORCH_MERGE_LOCK_TIMEOUT_S=300
ORCH_MERGE_DEFER_DELAY_S=60
ORCH_MERGE_DEFER_MAX_ATTEMPTS=5
# ORCH-036: executable self-deploy of the `deploy` stage. For the self-hosting repo
# (orchestrator) the stage REALLY restarts prod (8500) via a detached host hook;
# deploy_status: SUCCESS means proven health-ok, not an LLM declaration. Three
# deterministic phases (A: request approve, B: human Approved -> detached deploy,
# C: finalizer maps hook exit-code -> deploy_status). Non-self repos: unchanged
# synchronous ssh deploy. SECRETS / host paths live ONLY on the host — do NOT commit.
# SELF_DEPLOY_ENABLED -> global kill-switch (false -> legacy synchronous deploy for all).
# SELF_DEPLOY_REPOS -> CSV of repos where Phase A/B/C is REAL; empty -> only the
# self-hosting repo (orchestrator); others -> no-op (mirrors ORCH-35).
# DEPLOY_REQUIRE_MANUAL_APPROVE -> require a human Plane "Approved" before the prod
# deploy (true on rollout; full auto is ORCH-54).
# DEPLOY_FINALIZE_DELAY_S -> delay before the first/each finalize poll (>= hook+health).
# DEPLOY_FINALIZE_MAX_ATTEMPTS -> bounded finalize-defer budget (anti-livelock).
# DEPLOY_SSH_USER / DEPLOY_SSH_HOST -> ssh target for the host hook (DEPLOY_SSH_HOST
# empty -> detached deploy will NOT launch; set on the host).
# DEPLOY_HOOK_SCRIPT -> path to the hook ON THE HOST (relative to the repo).
# DEPLOY_HOST_REPO_PATH -> orchestrator clone path on the host.
# DEPLOY_PROD_SOURCE_IMAGE -> staging-validated image, retagged build-once (no rebuild).
# DEPLOY_PROD_TARGET_SERVICE / _PORT / _IMAGE / _COMPOSE_PROFILE -> prod compose profile.
# DEPLOY_PROD_PREV_IMAGE_FILE -> prod prev-image snapshot (separate from staging's).
ORCH_SELF_DEPLOY_ENABLED=true
ORCH_SELF_DEPLOY_REPOS=
ORCH_DEPLOY_REQUIRE_MANUAL_APPROVE=true
ORCH_DEPLOY_FINALIZE_DELAY_S=90
ORCH_DEPLOY_FINALIZE_MAX_ATTEMPTS=10
ORCH_DEPLOY_SSH_USER=slin
ORCH_DEPLOY_SSH_HOST=
ORCH_DEPLOY_HOOK_SCRIPT=scripts/orchestrator-deploy-hook.sh
ORCH_DEPLOY_HOST_REPO_PATH=/home/slin/repos/orchestrator
ORCH_DEPLOY_PROD_SOURCE_IMAGE=orchestrator-orchestrator-staging
ORCH_DEPLOY_PROD_TARGET_SERVICE=orchestrator
ORCH_DEPLOY_PROD_TARGET_PORT=8500
ORCH_DEPLOY_PROD_TARGET_IMAGE=orchestrator-orchestrator
ORCH_DEPLOY_PROD_COMPOSE_PROFILE=
ORCH_DEPLOY_PROD_PREV_IMAGE_FILE=.deploy-prev-image-prod
# 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:
# A (liveness): QG sub-check `check_staging_image_fresh` on the deploy-staging->deploy
# edge rebuilds orchestrator-orchestrator-staging from the validated commit + recreates
# 8501; FAIL -> rollback to development. (builds/recreate STAGING only, never prod.)
# B (safety): the Dockerfile stamps `org.opencontainers.image.revision`; the prod hook
# fail-closes (exit 1) before `docker tag` if SOURCE_IMAGE's label != EXPECTED_REVISION.
# ENABLED -> single kill-switch for A+B as a WHOLE (never "B without A"); false -> legacy.
# REPOS -> CSV of repos where the gate is REAL; empty -> only self-hosting (orchestrator).
ORCH_IMAGE_FRESHNESS_ENABLED=true
ORCH_IMAGE_FRESHNESS_REPOS=
# ORCH-053: stuck-task reconciler (sweeper for lost webhooks). A background daemon
# replays a missed stage transition through the SAME gates/handlers a webhook would,

View File

@@ -73,13 +73,39 @@ On stage `deploy-staging` your job is to run the staging test suite and write a
---
## Stage: `deploy` (Production Deploy — ORCH-36, future)
On stage `deploy` your job is to perform (or simulate) the production deployment and write a machine-readable verdict to `docs/work-items/<work_item_id>/14-deploy-log.md` with frontmatter field `deploy_status: SUCCESS|FAILED`.
## Stage: `deploy` (Production Deploy — ORCH-36, executable self-deploy)
This stage is only reached if the staging gate (`deploy-staging`) passed with `staging_status: SUCCESS`.
The verdict contract is unchanged: `docs/work-items/<work_item_id>/14-deploy-log.md` with
frontmatter field `deploy_status: SUCCESS|FAILED` (the gate `check_deploy_status` parses ONLY this).
**What changed (ORCH-36): WHO and WHEN writes that verdict, for the self-hosting repo.**
⚠️ **CRITICAL**: Do NOT trigger real production deploys unless explicitly instructed. Real docker/SSH deploys are handled by `scripts/orchestrator-deploy-hook.sh` (ORCH-36).
### Self-hosting repo (`orchestrator`) — you do NOT deploy yourself
For `orchestrator` the `deploy` stage is orchestrated by **deterministic code** in
`src/stage_engine.py` + `src/self_deploy.py`, NOT by you, and NOT by a "paper" `SUCCESS`:
- **Phase A** (entering `deploy`): the pipeline does NOT launch you. It sets the issue to an
approval-pending state and asks a human to flip the Plane status to **Approved**.
- **Phase B** (human Approved): the code launches a **detached host process**
(`ssh + setsid` → `scripts/orchestrator-deploy-hook.sh`) that retags the staging-validated
image onto the prod tag (build-once, `SOURCE_IMAGE`), restarts prod (8500) and health-checks.
The orchestrator NEVER restarts its own 8500 container from inside — that would kill the
worker mid-call.
- **Phase C** (finalizer): a deterministic finalizer-job in the NEW container reads the hook
exit-code, maps `0 → SUCCESS`, `1|2|other → FAILED`, writes `14-deploy-log.md` and drives the
existing contracts (`SUCCESS → done`, `FAILED → rollback to development`).
⚠️ **CRITICAL for self-hosting**: NEVER run `docker compose up -d orchestrator`, `--build`, or any
restart of 8500 from inside the agent. `deploy_status: SUCCESS` must reflect a REAL host health-ok,
never an LLM declaration. If you are ever launched on `deploy` for `orchestrator`, do nothing that
restarts prod — the host hook owns the restart.
### Non-self repos (e.g. `enduro-trails`) — unchanged synchronous ssh deploy
For non-self repos behaviour is unchanged: perform the production deployment (ssh to the project
host) and write the machine-readable verdict (`deploy_status: SUCCESS|FAILED`). Real docker/SSH
deploys go through `scripts/orchestrator-deploy-hook.sh` (parametrised; defaults are STAGING-safe).
---

View File

@@ -5,6 +5,8 @@
## [Unreleased]
### Added
- **Провенанс staging-образа перед BUILD-ONCE retag в прод (свежесть артефакта, INV-FRESH)** (ORCH-058): BUILD-ONCE retag (ORCH-036) промоутит staging-образ (`orchestrator-orchestrator-staging`) в прод **без rebuild**, полагаясь на «образ свеж и провалидирован» — гарантии не было: конвейер нигде не пересобирал staging-образ из провалидированного коммита, поэтому retag мог тихо промоутнуть УСТАРЕВШИЙ образ (инцидент LESSONS_ORCH-036 п.4 — зелёный деплой молча откатывал прод). Закрыто **двумя слоями (defense in depth), только для self-hosting**. Новый модуль `src/image_freshness.py` (контракт «never raise», по образцу `merge_gate`): `provenance_verdict` (чистая функция вердикта match/mismatch/fail-closed), `validated_revision` (`git rev-parse HEAD` в worktree валидированного коммита — единый якорь и для штампа A, и для `EXPECTED_REVISION` B), `image_revision` (OCI-лейбл `org.opencontainers.image.revision` через `docker image inspect`, `<no value>`/ошибка → пусто), `rebuild_staging_image` (ssh-хук `--build-staging`), `image_freshness_applies` (условность), `check_staging_image_fresh` (композитный QG). **Strategy A (liveness):** новый детерминированный QG-под-чек `check_staging_image_fresh` (зарегистрирован в `QG_CHECKS`, `src/qg/checks.py`) на ребре `deploy-staging → deploy` ПОСЛЕ merge-gate и ДО Phase A — пересобирает staging-образ из worktree валидированного коммита (хук `--build-staging`, `--build-arg GIT_SHA=<sha>`), пересоздаёт 8501 и прогоняет `staging_check.py --mode stub` против свежего 8501 (health + e2e, внутри staging-контейнера через `docker exec` — канон ORCH-048) → валидируем РОВНО тот артефакт (build + e2e), что промоутится в прод (AC-4); FAIL/не-ноль staging_check → откат на `development` (как merge-gate, кап `MAX_DEVELOPER_RETRIES`). `rebuild_staging_image` пробрасывает в хук **явный** staging-таргет (service/port/profile/container), исключая дрейф на прод 8500. Сборки/recreate/validate — **только staging (8501)**, прод (8500) не трогается. **Strategy B (safety):** `Dockerfile` штампует `LABEL org.opencontainers.image.revision=$GIT_SHA` (`ARG GIT_SHA`); `build_deploy_command` (`src/self_deploy.py`) пробрасывает `EXPECTED_REVISION`; хост-хук шагом 2b ПЕРЕД `docker tag` fail-closed сверяет лейбл `revision` у `SOURCE_IMAGE` с `EXPECTED_REVISION` — несовпадение / пустой лейбл / ошибка inspect → `exit 1` (FAILED → БАГ-8 откат), делает тихий промоут устаревшего образа структурно невозможным даже при проигравшей гонку/отключённой A. Хост-хук `scripts/orchestrator-deploy-hook.sh` расширен **обратно-совместимым** режимом `--build-staging` (пересборка+recreate staging, exit 0/1) и fail-closed guard'ом (активен только при заданном `EXPECTED_REVISION`). Единый kill-switch `ORCH_IMAGE_FRESHNESS_ENABLED` (true) включает A+B **как целое** (нет «B без A» = вечного fail-fast); область — `ORCH_IMAGE_FRESHNESS_REPOS` (CSV; пусто → только self-hosting `orchestrator`). Контракты НЕ менялись: `STAGE_TRANSITIONS` (под-гейт ребра, не стадия), exit-code-контракт хука (0/1/2), `map_exit_code_to_status`, `check_deploy_status`/`_parse_deploy_status`, БАГ-8, terminal-sync, merge-gate; схема БД — без миграций. ADR `docs/work-items/ORCH-058/06-adr/ADR-001-staging-image-provenance.md`, глобальный `docs/architecture/adr/adr-0008-staging-image-provenance.md`. Документация: `docs/architecture/README.md`, `docs/operations/DEPLOY_HOOK.md`, `docs/operations/STAGING.md`, `docs/operations/INFRA.md`, `.env.example`. Тесты: `tests/test_image_freshness.py`, `tests/test_deploy_hook_provenance.py`, `tests/test_deploy_build_once.py` (TC-06), `tests/test_deploy_hook_mapping.py` (TC-09), `tests/test_stage_engine.py::TestImageFreshnessGate`, `tests/test_qg_registry_snapshot.py`, `tests/test_config.py`.
- **Исполняемый самодеплой стадии `deploy` (стадия дёргает хост-хук, manual-approve)** (ORCH-036): стадия `deploy` перестаёт быть «бумажной» — для self-hosting репозитория `orchestrator` `deploy_status: SUCCESS` означает ДОКАЗАННЫЙ health-ok реального рестарта прод-контейнера (8500), а не декларацию LLM. Критический путь self-restart детерминирован (без LLM), по образцу merge-gate ORCH-043, и разбит на три фазы (`src/stage_engine.py` + новый модуль `src/self_deploy.py`): **Фаза A** (вход в `deploy`) — вместо запуска прод-deployer'а при `deploy_require_manual_approve=true` задача переводится в approval-pending (`set_issue_in_review`) и ждёт ручного approve; restart-safe маркер `approve-requested`. **Фаза B** (человек ставит статус Plane → `Approved`; `advance_stage(deploy, finished_agent=None)`) — запускается **detached host-процесс** (`ssh + setsid``scripts/orchestrator-deploy-hook.sh`, чтобы рестарт 8500 пережил гибель контейнера; орк НЕ убивает себя из docker.sock) с build-once retag staging-образа (`SOURCE_IMAGE`), ставится детерминированный **finalizer-job**; маркер `initiated` — идемпотентность повторного Approved. **Фаза C** (`run_deploy_finalizer`, reserved-agent `deploy-finalizer`, claim'ится новым контейнером после рестарта) — читает sentinel `result` (exit-code хука, записан host-обёрткой), `not-ready` → defer (бюджет `deploy_finalize_max_attempts`, restart-safe по `task_content`), маппит `0→SUCCESS / 1|2|иное→FAILED` (чистая функция `map_exit_code_to_status`, unit-тест), пишет `14-deploy-log.md` и вызывает `advance_stage(deploy, finished_agent="deployer")` → существующие контракты: `SUCCESS → done` + release merge-lease, `FAILED → откат БАГ-8 на development` + `set_issue_blocked`. Уведомления Plane+Telegram на approve-request / initiate / success / rollback (BR-5, ни одного «молчаливого» деплоя). Хост-хук `scripts/orchestrator-deploy-hook.sh` расширен **обратно-совместимым** `SOURCE_IMAGE`: при заданном — `docker tag $SOURCE_IMAGE $TARGET_IMAGE` перед `up -d --no-build` (деплой РОВНО протестированного образа, без `docker build`); не задан → прежнее поведение; exit-code-контракт (0/1/2) и health-loop (10×6с, авто-rollback) не тронуты. Restart-safe состояние — sentinel-файлы (`<repos_dir>/.deploy-state-<repo>/<work_item_id>/`), без миграции БД. Условность как ORCH-35: реальный самодеплой только для `is_self_hosting_repo("orchestrator")`; прочие репо (enduro-trails) — прежний синхронный ssh-путь агентом. Контракты НЕ менялись: `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, `check_deploy_status`/`_parse_deploy_status` (frontmatter-only), terminal-sync `deploy→done`, merge-gate (ORCH-43), БАГ-8. Флаг `DEPLOY_REQUIRE_MANUAL_APPROVE` остаётся `true` (полный авто — отдельная задача ORCH-54). Новые настройки: `ORCH_DEPLOY_REQUIRE_MANUAL_APPROVE` (true), `ORCH_DEPLOY_SSH_USER`, `ORCH_DEPLOY_SSH_HOST`, `ORCH_DEPLOY_HOOK_SCRIPT`, `ORCH_DEPLOY_PROD_SOURCE_IMAGE`, `ORCH_DEPLOY_PROD_TARGET_SERVICE/PORT/IMAGE`, `ORCH_DEPLOY_FINALIZE_DELAY_S`, `ORCH_DEPLOY_FINALIZE_MAX_ATTEMPTS`. ADR `docs/work-items/ORCH-036/06-adr/ADR-001-executable-self-deploy.md`, глобальный `docs/architecture/adr/adr-0007-executable-self-deploy.md`. Документация: `.openclaw/agents/deployer.md` (стадия `deploy` = вызов хука, запрет self-restart), `docs/operations/INFRA.md`, `docs/operations/DEPLOY_HOOK.md`. Тесты: `tests/test_deploy_hook_mapping.py`, `tests/test_deploy_approve.py`, `tests/test_deploy_routing.py`, `tests/test_deploy_rollback.py`, `tests/test_deploy_notifications.py`, `tests/test_deploy_build_once.py`, `tests/test_deploy_terminal_sync.py`, `tests/test_staging_precondition.py`, `tests/test_deploy_hook_rollback_sim.py`.
- **Sweeper потерянных webhook (реконсиляция застрявших стадий)** (ORCH-053): фоновый daemon-поток `src/reconciler.py` (паттерн `queue_worker`), который устраняет тихое застревание задач, когда конвейер не двигается из-за потерянного события (502 на ребилде инстанса, отсутствие ретраев у Plane/Gitea, неразрезолвленный `sha→branch` — класс инцидента ORCH-044). Реконсилятор периодически (`reconcile_interval_s`) доигрывает пропущенный переход **через те же штатные гейты/обработчики**, что и webhook, не дублируя логику конвейера: **F-1 gate-side** (`reconcile_gate_once`) — для задач `stage≠done`, без активного job и `age(updated_at) ≥ grace_for_stage(stage)` делает read-only пред-оценку канонического QG стадии; зелёный → продвижение строго через неизменный `stage_engine.advance_stage(..., finished_agent=None)`; красный → тишина (спам нотификаций структурно невозможен — `advance_stage` на красном гейте не вызывается вовсе); `analysis` F-1 не трогает (человеческий гейт). **F-2 plane-side** (`reconcile_plane_once`) — опрос Plane API per-project (новый `plane_sync.list_issues_by_state`, курсорная пагинация, never-raise) и реплей In Progress / Approved / Rejected через существующие `webhooks.plane.handle_status_start` / `handle_verdict` (async-обработчики вызываются из sync-потока через `asyncio.run`). **F-3** — усиление `sha→branch` в `handle_ci_status`: при неразрезолвленном sha — БД-fallback по единственной development-задаче repo (`db.get_development_tasks_by_repo`; неоднозначность → не резолвим, ложного матча нет), `logger.debug``logger.info` для видимости потерянного CI-события. Анти-дубль на создании задачи (`db.create_task_atomic` под process-wide `threading.Lock`: SELECT-exists→INSERT, проигравший в гонке reconcile↔webhook не плодит второй task/branch/worktree/стартовый analyst-job). Старт/стоп в `main.lifespan` (после `worker.start()` / перед `worker.stop()`), restart-safe, never-raise на единицу работы. Наблюдаемость (F-4): при разблокировке — лог-строка `reconciler: <wi> <stage> разблокирована (потерян webhook)` + Telegram (`reconcile_notify_unblock`) и блок `reconcile` в `GET /queue`. Kill-switches: `ORCH_RECONCILE_ENABLED` (глобально), `ORCH_RECONCILE_PLANE_ENABLED` (гасит только F-2), `ORCH_RECONCILE_INTERVAL_S` (120), `ORCH_RECONCILE_GRACE_DEFAULT_S` (600), `ORCH_RECONCILE_GRACE_OVERRIDES_JSON` (per-stage), `ORCH_RECONCILE_NOTIFY_UNBLOCK` (true). Схема БД и реестры (`STAGE_TRANSITIONS`/`QG_CHECKS`) НЕ менялись. ADR `docs/work-items/ORCH-053/06-adr/ADR-001-stuck-task-reconciler.md`, глобальный `docs/architecture/adr/adr-0007-reconciler.md`. Тесты: `tests/test_reconciler.py`, `tests/test_reconciler_plane.py`, `tests/test_gitea_sha_resolve.py`, `tests/test_config.py`.
- **Merge-gate: авто-rebase на текущий `origin/main` + повторный прогон тестов + сериализация мержей** (ORCH-043): детерминированный (без LLM) суб-гейт на ребре `deploy-staging → deploy`, выполняемый ПЕРЕД мержем PR деплоером. Закрывает класс гонок «две зелёные ветки в одном репо ломают `main`»: пайплайн валидирует ветку против того `main`, от которого она ответвилась, а не против `main` в момент мержа — между «ветка зелёная» и «ветка смержена» параллельная задача может сдвинуть `main` (семантический конфликт: git мержит без текстового конфликта, но совмещённый `main` красный). Для self-hosting репозитория `orchestrator` это означало бы красный `main` инструмента, обслуживающего ВСЕ проекты. Новый модуль `src/merge_gate.py` (контракт «never raise», все git-операции — в per-branch worktree, ORCH-2/S-4): `branch_is_behind_main` (`git merge-base --is-ancestor origin/main HEAD`), `auto_rebase_onto_main` (rebase + `git push --force-with-lease` ТОЛЬКО ветки задачи — `main` НИКОГДА не пушится; текстовый конфликт → `rebase --abort` + чистый worktree), `retest_branch` (`python -m pytest <target>` в догнанном worktree, бюджет `merge_retest_timeout_s`), файловый merge-lease (`acquire_merge_lease`/`release_merge_lease`, атомарный `O_CREAT|O_EXCL`, holder-aware release, реклейм протухшего/битого лиза — без изменения схемы БД). Новый quality-gate `check_branch_mergeable` (`src/qg/checks.py`, зарегистрирован в `QG_CHECKS`) композирует примитивы под лизом: kill-switch/вне-области → no-op pass; lock занят → `(False, "merge-lock busy")` (сигнал DEFER, не код-фолт); ветка свежая → pass (лиз ДЕРЖИТСЯ до мержа); отстала → rebase → конфликт = fail+release, чисто → retest → зелёный = pass (лиз держится) / красный|timeout = fail+release. Интеграция в `src/stage_engine.py` (суб-гейт на `deploy-staging`, БЕЗ новой стадии в `STAGE_TRANSITIONS`): pass → advance на `deploy`; «merge-lock busy» → DEFER (повторная постановка деплоера на `deploy-staging` с задержкой `available_at`, анти-дедлок при `max_concurrency=1`, restart-safe счётчик по `task_content`, лимит `merge_defer_max_attempts` → block+Telegram); конфликт/красный retest → ROLLBACK на `development` + ретрай developer-а (кап `MAX_DEVELOPER_RETRIES`, без бесконечного баунса). Лиз освобождается на `deploy→done`, на rollback и по webhook смерженного PR (`src/webhooks/gitea.py`). Новый параметр `enqueue_job(..., available_at_delay_s=...)` (`src/db.py`) — отложенная постановка без изменения схемы. Условность раскатки (зеркало ORCH-35): `merge_gate_repos` (CSV) или по умолчанию только self-hosting `orchestrator`; глобальный kill-switch `merge_gate_enabled`. Новые настройки `ORCH_MERGE_GATE_ENABLED` (true), `ORCH_MERGE_GATE_REPOS` (""), `ORCH_MERGE_RETEST_TIMEOUT_S` (600), `ORCH_MERGE_RETEST_TARGET` (tests/), `ORCH_MERGE_LOCK_TIMEOUT_S` (300), `ORCH_MERGE_DEFER_DELAY_S` (60), `ORCH_MERGE_DEFER_MAX_ATTEMPTS` (5). ADR `docs/work-items/ORCH-043/06-adr/ADR-001-merge-gate.md`, глобальный `docs/architecture/adr/adr-0006-merge-gate.md`. Тесты: `tests/test_merge_gate.py`, `tests/test_qg_merge_gate.py`, `tests/test_merge_gate_race.py`, `tests/test_stage_engine.py::TestMergeGate`, `tests/test_config.py`.
- **Режим `bump` live-трекера Telegram** (ORCH-042): новый `ORCH_TRACKER_MODE` (`Settings.tracker_mode`, дефолт `edit`) выбирает поведение карточки задачи. `edit` (как было) — карточка редактируется на месте (`editMessageText`). `bump` — на каждом обновлении старое сообщение удаляется и карточка отправляется заново вниз чата (best-effort `delete_telegram(старый_id)``send_telegram(text, disable_notification=True)``set_tracker_message_id(new_id)`), чтобы актуальный статус всегда был последним в чате при активной переписке. Инвариант «одна карточка на задачу» сохранён в обоих режимах: за один вызов `update_task_tracker` шлётся ≤1 нового сообщения; `set_tracker_message_id` вызывается ТОЛЬКО при успешном send (транзиентный `None` не затирает указатель); результат delete НЕ блокирует отправку новой карточки (delete-fail у сообщения >48ч → всё равно шлём новое). Резолюция режима в `notifications` (case-insensitive, trim): всё, что ≠ `"bump"` (включая пустое/мусор) → `edit` → нулевая регрессия и оркестратор не падает на любом значении флага. Новый low-level helper `delete_telegram(message_id) -> bool` (контракт «never raises», маркеры `_DELETE_GONE_MARKERS`): `ok:true` или «уже нет / нельзя удалить» → `True`; неизвестный `ok:false`/5xx/исключение → `False`; нет кредов → `False` без HTTP. Сигнатуры `send_telegram`/`edit_telegram`/`update_task_tracker` и схема БД (`tasks.tracker_message_id`) не менялись. ADR `docs/work-items/ORCH-042/06-adr/ADR-001-tracker-bump-mode.md`. Тесты: `tests/test_tracker_bump.py`, `tests/test_config.py`.
@@ -27,6 +29,7 @@
- Цепочка стадий: `... testing → deploy-staging → deploy → done` (была без `deploy-staging`).
### Fixed
- **Re-deploy после отката больше не зависает на `deploy`; `.env.example` дополнен** (ORCH-036, review-fix): sentinel-маркеры самодеплоя (`approve-requested`/`initiated`/`result`) ключуются по стабильному `work_item_id`, поэтому при FAILED-деплое и откате БАГ-8 (`deploy → development`) они оставались на диске — после фикса developer-ом и повторного захода задачи на `deploy` Фаза B по idempotency-guard видела STALE `initiated` и становилась no-op: detached-хук не перезапускался, finalizer не ставился, задача висела на `deploy` навсегда (нарушался retry-контракт стадии, AC-4/AC-10; устаревший `result` к тому же был бы перечитан новым finalizer'ом). Добавлен `self_deploy.clear_state(repo, work_item_id)` (never-raise, idempotent, рекурсивное удаление `<repos_dir>/.deploy-state-<repo>/<wi>/`), вызывается в ветке БАГ-8-отката `check_deploy_status` FAILED (`src/stage_engine.py`) и дополнительно в начале Фазы A (`_handle_self_deploy_phase_a`) — каждый новый прод-деплой-проход стартует с чистого состояния. Отдельно: канонический `.env.example` (CLAUDE.md правило №8, ТЗ §2.6) дополнен полным блоком новых дескрипторов `ORCH_SELF_DEPLOY_*` / `ORCH_DEPLOY_*` (плейсхолдеры, секреты не коммитятся) по образцу merge-gate ORCH-043. Контракты `STAGE_TRANSITIONS` / `QG_CHECKS` / `_parse_deploy_status` / БАГ-8 / merge-gate не тронуты. Тесты: `tests/test_deploy_rollback.py::test_tc11_re_deploy_after_rollback_not_wedged`, `tests/test_deploy_hook_mapping.py::test_clear_state_removes_all_markers_and_is_idempotent`.
- **Контейнер и агенты бегут под uid хоста (1000:1000), не root** (ORCH-040): оба сервиса в `docker-compose.yml` (`orchestrator`, `orchestrator-staging`) получили `user: "1000:1000"` (slin) — устраняет корень проблемы, при которой Claude-CLI агенты, запускаемые через `subprocess.Popen` внутри root-контейнера, создавали все артефакты конвейера (git worktree `/repos/_wt/...`, коммиты в `docs/work-items/...`) с владельцем `root:root` на хосте, из-за чего `git pull`/`git reset` под slin падали с `insufficient permission for adding an object` и каждый деплой требовал ручного `chown`. Теперь файлы сразу `slin:slin`. Доступ к docker.sock сохранён через `group_add: ["999"]` (МИНА 1 — НЕ удалена). SSH-маунт приведён к единому HOME агента: target `/root/.ssh``/home/slin/.ssh` (`/home/slin/.orchestrator-ssh:/home/slin/.ssh:ro`), синхронно с `HOME=/home/slin`, который launcher форсит в env Popen и git_env — устранён скрытый рассинхрон SSH-маунта с форсимым HOME. `src/agents/launcher.py` и `Dockerfile` НЕ менялись (numeric uid работает без записи в `/etc/passwd`; `safe.directory '*'` уже покрывает git над bind-mount). Требует host-prerequisites Owner (P-1…P-4, вне кода): блокер P-1 — `chown -R 1000:1000 /home/slin/.claude` для доступа uid 1000 к claude creds (иначе preflight заворачивает конвейер); прод-рестарт self — только в окно тишины (общий инстанс с enduro-trails), страховка — staging-гейт (adr-0003). ADR `docs/work-items/ORCH-040/06-adr/ADR-001-run-agents-as-host-uid.md`, глобальный `docs/architecture/adr/adr-0005-container-runs-as-host-uid.md`; INFRA.md обновлён (рантайм-uid, volumes/SSH target, host-prerequisites). Тесты: `tests/test_orch040_compose.py`.
- **Staging-чек B6 читает реестр из окружения работающего staging-инстанса** (ORCH-048): блок B6 «Registry: sandbox present, prod ET/ORCH absent» в `scripts/staging_check.py` давал **ложный FAIL** (`prod-ET=YES(BAD!)`, `prod-ORCH=YES(BAD!)`) при фактически исправной изоляции — единственный чек suite, который не ходил к инстансу по HTTP, а импортировал `src.projects` локально через host-path хак `sys.path.insert(0, "/repos/orchestrator")` + `importlib.reload`, строя реестр из `ORCH_PROJECTS_JSON` **process-env запускающего процесса**. При фактическом запуске деплоером с хоста переменная не задана → дефолт `_DEFAULT_PROJECTS` (ET+ORCH) → ложный FAIL → лишний откат `deploy-staging → development`. Решение (вариант «в», ADR-001): host-path хак удалён; suite канонически запускается ВНУТРИ контейнера `orchestrator-staging` через `docker exec … python3 /repos/orchestrator/scripts/staging_check.py` (`scripts/` доступен только через bind-mount, `import src.projects` резолвится через `PYTHONPATH=/app` из кода контейнера, env — `.env.staging`) → B6 читает реестр именно работающего инстанса, без HTTP-bootstrap и «курицы-яйца». Логика вердикта вынесена в чистую `_evaluate_b6(known) -> (passed, detail)` (инвариант `passed ⟺ SANDBOX ∈ known ∧ PROD_ET ∉ known ∧ PROD_ORCH ∉ known`, формат detail сохранён) + `_known_project_ids_from_registry()` / `_run_b6()` с детерминированным FAIL при недоступности источника (не ложный PASS, не необработанное исключение). Синхронно обновлены `.openclaw/agents/deployer.md` (команда стадии через `docker exec`) и `docs/operations/STAGING_CHECK.md`. `src/projects.py`, `.env*` и прочие чеки A/B4/B5/C не тронуты; реестр `QG_CHECKS` и `check_staging_status` (ADR-0003) не менялись. ADR `docs/work-items/ORCH-048/06-adr/ADR-001-b6-registry-via-in-container-run.md`. Тесты: `tests/test_staging_check_b6.py`.
- **Testing-гейт `check_tests_passed` читает `result:` наравне с `verdict:`/`status:`** (ORCH-047): парсер `_parse_tests_verdict` (`src/qg/checks.py`) теперь принимает три равноправных машиночитаемых поля frontmatter `13-test-report.md``result:` (канон промпта тестера `.openclaw/agents/tester.md`, `result: PASS|FAIL`), плюс легаси `verdict:` и `status:` (enduro-trails ET-001..ET-014); достаточно любого одного непустого. Устраняет рассинхрон контракта: тестер честно эмитил `result: PASS` без `verdict:`/`status:`, парсер попадал в ветку «нет машинного вердикта» → откат `testing → development` в петлю до исчерпания `MAX_DEVELOPER_RETRIES` (наблюдалось на ORCH-17; ORCH-016 прошёл лишь из-за избыточного дублирования полей). Семантика приоритетов сохранена и распространена на все три поля через объединённую строку: negative-токен в любом поле авторитетен (перебивает positive), наборы токенов заморожены (обратная совместимость). Сигнатура гейта, имя и реестр `QG_CHECKS` не менялись. ADR `docs/work-items/ORCH-047/06-adr/ADR-001-result-field-in-tests-gate.md`. Тесты: `tests/test_qg.py::TestCheckTestsPassed`.

View File

@@ -1,8 +1,22 @@
FROM python:3.12-slim
# ORCH-058 (Strategy B): stamp the image with the git commit it was built from so
# the deploy hook can fail-close if a stale staging image would be promoted to prod
# (INV-FRESH). Passed at build time via `--build-arg GIT_SHA=<sha>` (the staging
# rebuild in check_staging_image_fresh / the --build-staging hook mode supplies it).
# Without the build-arg the label is empty -> the hook treats it as a mismatch
# (fail-closed). The OCI-standard key is read by `docker image inspect`.
ARG GIT_SHA=""
LABEL org.opencontainers.image.revision=$GIT_SHA
WORKDIR /app
RUN apt-get update -qq && apt-get install -y -qq openssh-client git && rm -rf /var/lib/apt/lists/*
# git operations run as root over bind-mounted /repos (may be owned by host uid) -> trust it.
RUN git config --system --add safe.directory '*'
# ORCH-58: compose runs the container as uid:gid 1000:1000 (ORCH-40), but the base
# image has no passwd entry for uid 1000 -> ssh/whoami fail with
# "No user exists for uid 1000" (rc=255), breaking the detached self-deploy ssh
# launch (ORCH-36 Phase B). Create a real user 1000 with a home dir so getpwuid()
# resolves and ssh can start.
RUN groupadd -g 1000 app && useradd -u 1000 -g 1000 -m -d /home/slin -s /bin/bash slin
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY src/ ./src/

View File

@@ -26,9 +26,15 @@ services:
environment:
- ORCH_REPOS_DIR=/repos
- ORCH_HOST_REPOS_DIR=/home/slin/repos
# legacy enduro deployer (read via os.environ, keep as-is):
- DEPLOY_SSH_USER=slin
- DEPLOY_SSH_HOST=127.0.0.1
- DEPLOY_HOOK_SCRIPT=/home/slin/bin/enduro-deploy-hook.sh
# ORCH-036 self-deploy (read via pydantic ORCH_ prefix; host-network -> 127.0.0.1, ssh key mounted):
- ORCH_DEPLOY_SSH_USER=slin
- ORCH_DEPLOY_SSH_HOST=127.0.0.1
- ORCH_DEPLOY_HOOK_SCRIPT=scripts/orchestrator-deploy-hook.sh
- ORCH_DEPLOY_HOST_REPO_PATH=/home/slin/repos/orchestrator
group_add:
- "999"

View File

@@ -35,7 +35,7 @@ created → analysis → architecture → development → review → testing →
| deploy | — | `check_deploy_status` | 14-deploy-log.md (`deploy_status:`) |
| done | — | — | — |
**Реестр QG** (`QG_CHECKS`): check_analysis_approved, check_analysis_complete, check_architecture_done, check_ci_green, check_review_approved, check_tests_passed, check_reviewer_verdict, check_tests_local, check_deploy_status, check_staging_status, check_branch_mergeable (ORCH-043).
**Реестр QG** (`QG_CHECKS`): check_analysis_approved, check_analysis_complete, check_architecture_done, check_ci_green, check_review_approved, check_tests_passed, check_reviewer_verdict, check_tests_local, check_deploy_status, check_staging_status, check_branch_mergeable (ORCH-043), check_staging_image_fresh (ORCH-058).
**Канон гейтов:** машинные вердикты читаются ТОЛЬКО из YAML-frontmatter, никогда из прозы. Лог-файлы мержатся в `origin/main` отдельным PR; гейт читает из `origin/main`.
@@ -53,6 +53,61 @@ created → analysis → architecture → development → review → testing →
Подробнее: [adr-0006](adr/adr-0006-merge-gate.md), детально — `docs/work-items/ORCH-043/06-adr/ADR-001-merge-gate.md`.
### Исполняемый самодеплой стадии `deploy` (ORCH-36)
`deploy` перестаёт быть «бумажной»: для self-hosting (`is_self_hosting_repo`) стадия
РЕАЛЬНО деплоит прод (8500) через хост-хук `scripts/orchestrator-deploy-hook.sh`,
а `deploy_status: SUCCESS` означает доказанный health-ok, не декларацию LLM. Три фазы
(детерминированно, без LLM в критическом пути self-restart):
- **Фаза A (вход в `deploy`)** — при `deploy_require_manual_approve=true` вместо запуска
прод-deployer выставляется approval-pending статус Plane + запрос approve
(Plane-коммент + Telegram). Перехват в `advance_stage` ПОСЛЕ `check_staging_status`
и merge-gate.
- **Фаза B (Plane → `Approved`)** — `advance_stage(deploy, finished_agent=None)`
запускает **detached host-процесс** (ssh + setsid → хук с прод-параметрами +
build-once retag `SOURCE_IMAGE`) и ставит детерминированный **finalizer-job**;
маркер `initiated` — идемпотентность. Возврат БЕЗ advance (вердикта ещё нет).
- **Фаза C (finalizer)** — новый контейнер после рестарта читает sentinel `result`
(exit-code хука), маппит `0→SUCCESS / иначе→FAILED`, пишет `14-deploy-log.md`,
вызывает `advance_stage(deploy, finished_agent="deployer")` → существующие контракты:
`SUCCESS → done`, `FAILED → откат БАГ-8 на development`.
Approve = смена статуса Plane на `Approved` (status-only verdict model; комментарии
не управляют конвейером). На старте — обязательный ручной approve (флаг `true`); полный
авто — отдельная задача (ORCH-54). Условность как ORCH-35: реально для `orchestrator`,
прочие репо — прежний синхронный ssh-деплой агентом. Контракты не меняются:
`STAGE_TRANSITIONS`, реестр QG, `check_deploy_status`/`_parse_deploy_status`, БАГ-8,
terminal-sync, merge-gate, exit-code-контракт хука. Restart-safe состояние —
sentinel-файлы (`<repos_dir>/.deploy-state-<repo>/<wi>/`), без миграции БД.
Подробнее: [adr-0007](adr/adr-0007-executable-self-deploy.md), детально —
`docs/work-items/ORCH-036/06-adr/ADR-001-executable-self-deploy.md`.
### Свежесть артефакта BUILD-ONCE: провенанс staging-образа (ORCH-058 — реализовано)
BUILD-ONCE retag (ORCH-36) промоутит `SOURCE_IMAGE=orchestrator-orchestrator-staging` в прод
**без rebuild**, полагаясь на «staging-образ свеж и провалидирован». Этой гарантии нет:
конвейер нигде не пересобирает staging-образ из провалидированного коммита → retag мог тихо
промоутнуть УСТАРЕВШИЙ образ (инцидент LESSONS_ORCH-036 п.4 — зелёный деплой молча
откатывал прод). ORCH-058 обеспечивает инвариант `INV-FRESH` **двумя слоями** (defense in
depth), только для self-hosting:
- **A — пересборка (liveness):** детерминированный QG-под-чек `check_staging_image_fresh` на
ребре `deploy-staging → deploy` ПОСЛЕ merge-gate и ДО Phase A пересобирает
`orchestrator-orchestrator-staging` из worktree валидированного коммита
(`--build-arg GIT_SHA=<sha>`, OCI-лейбл `org.opencontainers.image.revision`), пересоздаёт
8501 и прогоняет `staging_check` против свежего образа → валидируем и промоутим один
артефакт. FAIL → откат на `development` (как merge-gate). Сборки/recreate — ТОЛЬКО staging.
- **B — fail-closed guard (safety):** хук шагом 2b ПЕРЕД `docker tag` сверяет лейбл `revision`
у `SOURCE_IMAGE` с `EXPECTED_REVISION` (пробрасывает `build_deploy_command`). Несовпадение
/ пустой лейбл / пустой ожидаемый SHA / ошибка inspect → `exit 1` → FAILED (БАГ-8 откат),
прод не трогается. Делает тихий промоут устаревшего образа структурно невозможным даже при
отключённой/проигравшей гонку A.
Якорь «провалидированного коммита» — `git rev-parse HEAD` worktree ПОСЛЕ merge-gate (один
helper `validated_revision` питает и штамп A, и `EXPECTED_REVISION` B). Единый kill-switch
`image_freshness_enabled` включает A+B **как целое** (нет «B без A» = вечного fail-fast);
`image_freshness_repos` (пусто → self-hosting). `STAGE_TRANSITIONS`, exit-code хука (0/1/2),
`check_deploy_status`, БАГ-8, merge-gate, схема БД — НЕ меняются (под-гейт ребра + лейбл
образа, без миграций). Подробнее: [adr-0008](adr/adr-0008-staging-image-provenance.md),
детально — `docs/work-items/ORCH-058/06-adr/ADR-001-staging-image-provenance.md`.
### Reconciler: реконсиляция потерянных webhook (ORCH-053 — реализовано)
Конвейер продвигается только входящими webhook; потерянное событие (502 на ребилде,
нет ретраев у Plane/Gitea, неразрезолвленный `sha→branch`) → задача застревает молча
@@ -139,4 +194,4 @@ never-raise на единицу работы; тишина при синхрон
Схема БД, потоки данных, resilience-слой, детали Dockerfile — [internals.md](internals.md).
---
*Актуально на 2026-06-06. Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py. ORCH-043: merge-gate — design (см. adr-0006), реализация в ветке feature/ORCH-043. ORCH-053: reconciler — реализовано (см. adr-0007, src/reconciler.py).*
*Актуально на 2026-06-07. Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py. Статусы доработок: ORCH-036 (исполняемый самодеплой `deploy`, adr-0007) — реализовано; ORCH-043 (merge-gate, adr-0006) — design, ветка feature/ORCH-043; ORCH-053 (reconciler, adr-0007, src/reconciler.py) — реализовано; ORCH-058 (провенанс staging-образа: check_staging_image_fresh + staging_check свежего образа + хук-guard, adr-0008) — реализовано в ветке feature/ORCH-058 (обновлять также при изменении src/image_freshness.py, scripts/orchestrator-deploy-hook.sh, Dockerfile).*

View File

@@ -0,0 +1,64 @@
# ADR-0007: Исполняемый самодеплой стадии `deploy` (Вариант B, ORCH-36)
## Статус
Accepted (design) — реализация в ветке `feature/ORCH-036`.
## Контекст
Стадия `deploy` была «бумажной»: deployer-агент писал `deploy_status:` в
`14-deploy-log.md`, гейт `check_deploy_status` парсил вердикт и двигал
`deploy → done`. Реального деплоя не было. ORCH-36 делает стадию исполняемой для
self-hosting (`orchestrator`), сохраняя прежний ssh-путь для остальных репо.
Три ограничения формируют дизайн (детально — `docs/work-items/ORCH-036/06-adr/ADR-001`):
1. **Self-restart**: рестарт прод-контейнера 8500 убивает in-container процесс →
рестарт делает ВНЕШНИЙ host-процесс.
2. **Status-only verdict model**: approve = смена статуса Plane на `Approved`
(комментарии не управляют конвейером).
3. **Гонка гейта**: вердикт нельзя читать до завершения асинхронного хука.
## Решение
Для self-hosting стадия `deploy` исполняется в три фазы детерминированным кодом
(без LLM в критическом пути self-restart):
- **Фаза A (вход в `deploy`)** — для self + `deploy_require_manual_approve=true`
вместо запуска прод-deployer выставляется approval-pending статус Plane + запрос
approve (Plane-коммент + Telegram). Перехват в `advance_stage` на ребре
`deploy-staging → deploy` (после `check_staging_status` и merge-gate).
- **Фаза B (Plane → Approved)** — `advance_stage(deploy, finished_agent=None)`
запускает **detached host-процесс** (ssh + setsid → `orchestrator-deploy-hook.sh`
с прод-параметрами и build-once retag) и ставит **детерминированный finalizer-job**
с задержкой; маркер `initiated` — идемпотентность. Возврат БЕЗ advance.
- **Фаза C (finalizer)** — после рестарта новый контейнер дочитывает sentinel
`result` (exit-code хука), маппит `0→SUCCESS / иначе→FAILED`, пишет
`14-deploy-log.md`, вызывает `advance_stage(deploy, finished_agent="deployer")`
→ существующие контракты: `SUCCESS → done`, `FAILED → откат БАГ-8 на development`.
### Ключевые инварианты (НЕ меняются)
`STAGE_TRANSITIONS`, реестр QG, `check_deploy_status` / `_parse_deploy_status`
(frontmatter only), откат БАГ-8, terminal-sync `deploy → done`, merge-gate (ORCH-43),
exit-code-контракт хука (0/1/2).
### Новое (сквозное)
- **Детерминированный job-kind** `deploy-finalizer` в очереди (reserved-agent, не
LLM): read-result | defer | map+write+advance. Зеркалит детерминизм merge-gate.
- **Approve-флаг** `deploy_require_manual_approve` (дефолт `true`; полный авто —
отдельная задача после набора метрик доверия, ORCH-54).
- **Build-once**: опциональный `SOURCE_IMAGE` retag в хуке (обратно совместимо).
- **Restart-safe состояние** деплоя — sentinel-файлы под
`<repos_dir>/.deploy-state-<repo>/<wi>/` (как merge-lease), БЕЗ миграции БД.
### Условность
Вся логика — только для `is_self_hosting_repo(repo)` (как ORCH-35). Прочие репо
деплоятся прежним синхронным ssh-путём агентом.
## Последствия
- `deploy_status: SUCCESS` доказан реальным health-ok; критический путь self-restart
детерминирован.
- Вводится новая под-компонента (finalizer job-handler) → изменение помечено
`arch:major-change`.
- Approve вписан в status-only модель: restart-safe, аудируемо, идемпотентно.
- На старте — обязательный ручной approve; молчаливых деплоев нет (Plane+Telegram).
## Связанные ADR
`adr-0003` (staging-gate), `adr-0006` (merge-gate), `adr-0005` (run-as-host-uid).
Детальный per-work-item: `docs/work-items/ORCH-036/06-adr/ADR-001-executable-self-deploy.md`.

View File

@@ -0,0 +1,77 @@
# ADR-0008: Провенанс staging-образа перед BUILD-ONCE retag в прод (ORCH-058)
## Статус
Accepted (design) — реализация в ветке `feature/ORCH-058-self-deploy-retag-staging`.
Метка: `arch:major-change`.
> Примечание о нумерации: в `adr/` исторически два файла `adr-0007-*`
> (`executable-self-deploy`, `reconciler`) — пред-существующая коллизия. Этот ADR берёт
> следующий свободный номер **0008**; коллизию 0007 не трогаем (вне объёма ORCH-058).
## Контекст
ORCH-36 (`adr-0007-executable-self-deploy`) сделал стадию `deploy` исполняемой для
self-hosting: Phase B запускает host-хук, который шагом **2b** (BUILD-ONCE) делает
`docker tag $SOURCE_IMAGE → $TARGET_IMAGE` **без rebuild** — «прод = ровно тот артефакт,
что прошёл staging». Предпосылка: staging-образ свеж и собран из провалидированного кода.
**Этой гарантии нет.** Конвейер нигде не пересобирает `orchestrator-orchestrator-staging`
из провалидированного коммита; `deploy-staging` лишь гоняет `staging_check.py` против уже
работающего 8501. Инцидент (LESSONS_ORCH-036 п.4): staging-образ не пересобрали → проверка
прошла против старого кода → retag промоутнул СТАРЫЙ образ → прод **молча** откатился на
2-дневный код. Зелёный гейт = ложный позитив. Самый опасный из 4 багов: не падает, а тихо
откатывает инструмент, обслуживающий все проекты.
## Решение
Гарантировать `INV-FRESH`: в прод промоутится только образ, собранный из коммита,
провалидированного `deploy-staging` для данной задачи; иначе fail-fast (`FAILED` → откат на
`development`, БАГ-8), прод не трогается. Достигается **двумя взаимодополняющими слоями**
(defense in depth), только для self-hosting (условность как ORCH-35/36/43):
- **A — пересборка (liveness).** На ребре `deploy-staging → deploy`, ПОСЛЕ merge-gate и ДО
Phase A, детерминированный QG-под-чек `check_staging_image_fresh` пересобирает
`orchestrator-orchestrator-staging` из worktree валидированного коммита
(`--build-arg GIT_SHA=<sha>`, лейбл `org.opencontainers.image.revision`), пересоздаёт 8501
и прогоняет `staging_check`. FAIL → откат на `development`. Так валидируемый и промоутимый
артефакт — один и тот же; гарантирует наличие зелёного пути (нет вечного fail-fast).
- **B — fail-closed guard (safety).** Хук шагом 2b ПЕРЕД `docker tag` сверяет лейбл
`revision` образа `SOURCE_IMAGE` с `EXPECTED_REVISION` (пробрасывает `build_deploy_command`).
Несовпадение / пустой лейбл / пустой ожидаемый SHA / ошибка inspect → `exit 1` → FAILED.
Делает тихий промоут устаревшего образа структурно невозможным даже при отключённой/
проигравшей гонку A.
**Якорь провалидированного коммита**`git rev-parse HEAD` в worktree ПОСЛЕ merge-gate
(post-rebase tree, который ре-тестирован и сольётся в `main`). Один helper
`validated_revision(repo, branch)` питает и штамп сборки (A), и `EXPECTED_REVISION` (B).
**Условность и kill-switch:** единый `image_freshness_enabled` (вкл/выкл A+B как целое,
чтобы не было «B без A» = вечный fail-fast), `image_freshness_repos` (CSV; пусто →
self-hosting). Все настройки с префиксом `ORCH_`.
### Что НЕ меняется
`STAGE_TRANSITIONS` (набор стадий — под-гейт ребра, не стадия), exit-code хука (0/1/2),
`map_exit_code_to_status`, `check_deploy_status`/`_parse_deploy_status`, БАГ-8, terminal-sync,
merge-gate, Phase A/B/C. Схема БД — без миграций (провенанс в лейбле образа, не в БД).
### Что добавляется (сквозное)
- QG `check_staging_image_fresh` в реестре `QG_CHECKS` (+ snapshot-тест), wired через
`_handle_image_freshness` в `stage_engine` (рядом с merge-gate).
- Режим хука `--build-staging` (build из worktree + recreate 8501; STAGING-safe дефолты).
- OCI-лейбл `org.opencontainers.image.revision` в `Dockerfile` (`ARG GIT_SHA`).
- Helpers `validated_revision` / `rebuild_staging_image` в `self_deploy.py` (never-raise).
## Последствия
- Класс «тихого регресса прод» закрыт структурно (B); валидный деплой всегда доходит до
зелёного (A) — устранён ручной bootstrap-разрыв пересборки staging.
- Латентность ребра растёт (build + recreate + повторный staging_check); `staging_check`
гоняется дважды (soft pre-check агента + авторитетный код) — плата за «валидируем =
промоутим».
- Все сборки/recreate — ТОЛЬКО staging (8501); прод (8500) не трогается; `main` не пушится.
Новая под-компонента → `arch:major-change`.
## Связанные ADR
`adr-0007-executable-self-deploy` (BUILD-ONCE, Phase A/B/C), `adr-0006-merge-gate` (образец
edge-под-гейта), `adr-0003-staging-gate` (условность self-hosting), `adr-0005`
(run-as-host-uid). Детальный per-work-item: `docs/work-items/ORCH-058/06-adr/ADR-001-staging-image-provenance.md`.

View File

@@ -0,0 +1,120 @@
# Lessons Learned — 2026-06-06 (вечер): ORCH-36 + ORCH-53 → прод (эпик ORCH-54)
## Итог
Закрыты две задачи эпика ORCH-54 (автономное внедрение): **ORCH-36** (исполняемый
самодеплой стадии `deploy`) и **ORCH-53** (sweeper/reconciler потерянных webhook).
Обе прошли конвейер через рабочий merge-gate (ORCH-43), но финальный мерж+деплой
потребовал **ручного разрыва bootstrap-цикла** — задача, добавляющая автодеплой, сама
не может задеплоить себя через старую логику. Reconciler доказал себя **в первую секунду
после старта** — разблокировал две реально застрявшие задачи (ORCH-036 и ET-013).
Эпик ORCH-54: **4 из 6 в проде** (ORCH-40 права, ORCH-43 merge-gate, ORCH-36 деплой,
ORCH-53 reconciler). Осталось: ORCH-51 (окно/HA), обкатка полностью автономного деплоя.
---
## 1. 🔴 Bootstrap-парадокс самодеплоя (ORCH-36)
### Симптом
ORCH-36 застряла в петле `deploy → development`:
```
QG check_deploy_status — failed: Deploy log not found (14-deploy-log.md)
→ deployer verdict FAILED, rolled back deploy → development
```
deployer запускался (exit 0), но **не писал** `14-deploy-log.md` → гейт FAILED → откат →
снова deployer → бесконечный цикл (jobs 140→142→143...).
### Корень
Классический bootstrap самохостинга: **новая deploy-логика лежит в ветке, старая работает
в проде**. ORCH-36 учит deployer писать лог по результату РЕАЛЬНОГО деплоя (через хост-хук),
но прод-deployer работает по СТАРОМУ промпту, который для self-репо реального деплоя не делает
и SUCCESS-лог не пишет. Нет лога → FAILED → откат.
### Урок
**Self-репо не может задеплоить сам себя через старую логику.** Нужен разовый ручной разрыв
цикла: домержить + задеплоить руками ОДИН раз, дальше конвейер катит своей же новой логикой.
Тот же паттерн был у ORCH-40/43. Это структурное свойство любой задачи, меняющей
deploy/merge-механику самого оркестратора — закладывать ручной bootstrap-шаг в план.
---
## 2. 🔴 Merge-конфликт при последовательном ручном мерже двух задач
### Симптом
PR #56 (ORCH-53) смержен первым — чисто. PR #55 (ORCH-36) сразу после → **CONFLICT 409**:
`.env.example`, `CHANGELOG.md`, `docs/architecture/README.md`, `docs/operations/INFRA.md`,
**`src/config.py`**.
### Корень
После мержа PR #56 `main` ушёл вперёд → PR #55 валидировался против СТАРОГО main (точки
ответвления), а мержится в НОВЫЙ. Это ровно класс «main ушёл вперёд», который чинит
merge-gate (ORCH-43) — но при РУЧНОМ мерже через Gitea API merge-gate не участвует.
### Решение
- **merge main→ветку, НЕ rebase.** Rebase 9 коммитов = 9 потенциальных конфликт-разборов;
один merge-коммит = ОДИН разбор. Быстрее и безопаснее для большого набора коммитов.
- Конфликт в `src/config.py` был чисто **аддитивный**: ветка ORCH-36 добавляла блок
`self_deploy_*` настроек, main (ORCH-53) — блок `reconcile_*`. Нужны **ОБА** блока →
склеить, убрав только git-маркеры (`<<<<<<<`/`=======`/`>>>>>>>`). Обязательно после —
`python3 -c 'import ast; ast.parse(...)'` для проверки синтаксиса.
- docs/.env/CHANGELOG конфликты — тоже аддитивные (обе стороны добавляют строки) → union.
### Грабли
⚠️ `grep -rE '^(<<<<<<<|=======|>>>>>>>)'` по `docs/work-items/*/13-test-report.md` даёт
**ЛОЖНЫЕ срабатывания** — там `=======` это markdown-разделители таблиц/секций, не
git-конфликты. Проверять реальные конфликтные файлы поимённо, не доверять глобальному grep.
---
## 3. Review-гейт поймал 2 реальных P1 ДО прода (ORCH-36)
reviewer завернул первую версию (`verdict: REQUEST_CHANGES`), конвейер сам откатил
dev→review→fix→APPROVED. Два P1:
1. **sentinel-маркеры self-deploy (`initiated`/`result`/`approve-requested`) не чистились на
rollback** → при возврате задачи человек ставит Approved, а устаревший маркер ломает фазу B.
2. **нет `.env.example` для новых флагов** + процедуры «approve→деплой» в `INFRA.md`.
Урок: merge-gate + review отрабатывают как задумано — брак не уходит в прод автономно.
Это и есть ценность эпика: система фильтрует сама.
---
## 4. 🔥 Reconciler доказал себя мгновенно (ORCH-53)
В первую секунду после рестарта прода (21:24 UTC):
```
reconciler: ORCH-036 development разблокирована (потерян webhook)
reconciler: ET-013 development разблокирована (потерян webhook)
```
Sweeper нашёл и разблокировал ДВЕ реально застрявшие задачи — включая саму ORCH-036 из
bootstrap-петли, и старое зависание ET-013 (enduro-trails). Ручной heartbeat-watchdog,
который раньше держал Стрим, **больше не нужен** — система чинит застревания сама.
---
## 5. Операционные мелочи (закрепить)
- **Заголовки ORCH-задач ≤80 символов.** QG-0 (`check title length`) заворачивает старт
конвейера, если длиннее. ORCH-53 был 83 символа → завернул на старте → подрезали до 71.
- **Developer-таймаут 1800с (30 мин) мал для мясных задач.** 1-й заход developer'а ORCH-36
(деплой-хук + Telegram-кнопка + callback) упёрся в лимит → SIGKILL (exit -9). Спас
resilience-ретрай (ORCH-1b): attempt 2, наработки в worktree между попытками сохранились.
Если упирается систематически — поднять `agent_timeout_seconds` (override per-agent) или
дробить задачу.
- **Время хоста ≠ UTC.** Файлы worktree датируются по мск (+3), БД/системное — UTC. Не баг,
но путает сверки `etime`/`updated_at`/`finished_at`. Сверять по одному источнику.
- **Gitea merge auth:** заголовок строго `Authorization: token <ORCH_GITEA_TOKEN>` (формат
`token `, буквально). НЕ маскировать токен плейсхолдером `***` → иначе 401.
POST `/repos/admin/orchestrator/pulls/{N}/merge`, body `{"Do":"merge"}`.
- **approve прод-деплоя 8500 = Telegram-кнопка** (решение Owner), флаг
`DEPLOY_REQUIRE_MANUAL_APPROVE=true` по дефолту.
- **max_concurrency=1 оставлен сознательно** (решение Owner): одна БД/очередь на все
проекты, последовательное выполнение надёжнее. НЕ поднимать без явного запроса.
---
## Состояние прода после деплоя (21:24 UTC, main `1ff8d85`)
- `src/self_deploy.py` — в проде (исполняемый деплой, 3 фазы A/B/C)
- `src/reconciler.py` — в проде (фоновый sweeper, уже разблокировал 2 задачи)
- uid 1000, health `{"status":"ok"}`, preflight True (Claude Code 2.1.142)
- Деплой-скрипт с авто-rollback: исходник в workspace `temp/deploy_36_53.sh`

View File

@@ -0,0 +1,78 @@
# Lessons Learned — 2026-06-07 (утро): ORCH-36 self-deploy bootstrap — каскад неготовности инфры
## Итог
ORCH-36 (исполняемый самодеплой стадии `deploy`) **замкнулась в проде** — конвейер
впервые задеплоил сам себя по полному циклу Phase A→B→C (approve → детачед ssh-хук →
finalizer). Но путь до Done вскрыл **четыре слоя неготовности инфраструктуры**, каждый из
которых требовал ручного bootstrap-разрыва: задача про автодеплой не может задеплоить
сама себя, пока её же механизм + инфра не в проде.
Эпик ORCH-54: **4/6 в проде** (ORCH-40 права, ORCH-43 merge-gate, ORCH-36 самодеплой,
ORCH-53 reconciler). Конвейер автономен: мержит → катит в прод → чинит застрявшее.
---
## Каскад из 4 инфра-багов (все вскрылись только при РЕАЛЬНОМ деплое)
### 1. 🔴 uid 1000 без записи в `/etc/passwd` → ssh/whoami падают
**Симптом:** `self-deploy initiate failed: ssh launch failed (rc=255): No user exists for
uid 1000`. **Корень:** регрессия ORCH-40 — compose запускает контейнер под `1000:1000`,
но базовый образ `python:3.12-slim` не имеет passwd-записи для 1000. SSH-клиент (и
`whoami`, `getpwuid()`) отказываются стартовать без валидного юзера.
**Фикс:** в `Dockerfile``groupadd -g 1000 app && useradd -u 1000 -g 1000 -m -d
/home/slin -s /bin/bash slin`. Rebuild + recreate. Коммит `64e031a`.
**Урок:** при переводе контейнера на non-root uid (ORCH-40) ОБЯЗАТЕЛЬНО создавать passwd-
запись в образе, иначе ssh/git/любой инструмент с getpwuid() ломается. Проверять
`docker exec <c> whoami` после смены uid.
### 2. 🔴 env-префикс: `DEPLOY_*` vs `ORCH_DEPLOY_*` (pydantic не видит)
**Симптом:** `ssh: Could not resolve hostname : No address associated with hostname`
host пустой, хотя в compose `DEPLOY_SSH_HOST=127.0.0.1` задан. **Корень:** `Settings`
имеет `env_prefix = "ORCH_"` → читает ТОЛЬКО `ORCH_DEPLOY_SSH_HOST`. Старые
`DEPLOY_*` (без префикса) предназначались легаси enduro-деплоеру (читает через
`os.environ` напрямую) и pydantic их игнорирует → дефолт `host=""`. Доп: `DEPLOY_HOOK_SCRIPT`
указывал на `enduro-deploy-hook.sh`, не на orchestrator-хук.
**Фикс:** в `docker-compose.yml` добавлены `ORCH_DEPLOY_SSH_USER/HOST`,
`ORCH_DEPLOY_HOOK_SCRIPT=scripts/orchestrator-deploy-hook.sh`,
`ORCH_DEPLOY_HOST_REPO_PATH` (легаси `DEPLOY_*` оставлены для enduro). Коммит `115519e`.
**Урок:** все настройки, читаемые через pydantic Settings, ДОЛЖНЫ иметь префикс `ORCH_`.
Проверять резолв: `docker exec <c> python3 -c 'from src.config import settings; print(settings.deploy_ssh_host)'`.
### 3. 🔴 `/var/log/orchestrator` принадлежит root → хук падает на tee
**Симптом:** `tee: /var/log/orchestrator/deploy-hook.log: Permission denied`, хук exit 1.
**Корень:** лог-директория `root:root`, а хук бежит под `slin`. **Фикс:** `chown -R
slin:slin /var/log/orchestrator` на хосте.
**Урок:** все пути, в которые пишет хост-хук (логи, sentinel, prev-image), должны быть
writable юзером, под которым ssh-сессия. Заложить создание/chown в provisioning хоста.
### 4. 🔴🔴 BUILD-ONCE retag берёт УСТАРЕВШИЙ staging-образ → катит регресс (ВАЖНО)
**Симптом:** деплой «зелёный» (result=0, health ok), но прод откатился на код 2-дневной
давности — пропал `deploy-finalizer` (`Unknown agent: deploy-finalizer`), задача не
закрылась. **Корень:** хук делает `BUILD-ONCE: retag orchestrator-orchestrator-staging →
orchestrator-orchestrator` (без rebuild, by design ORCH-36 BR-6). Дизайн предполагал
«staging-образ = свежий, провалидированный». В РЕАЛЬНОСТИ `orchestrator-orchestrator-staging`
никто не пересобрал из нового main → retag катил в прод СТАРЫЙ образ → бесконечная петля:
каждый Phase B возвращал прод в прошлое, finalizer (новый код) исчезал, Phase C не мог
закрыть задачу.
**Фикс (ручной разрыв):** пересобрать `orchestrator-orchestrator-staging` из актуального
main ПЕРЕД retag → тогда хук катит свежий код. После этого Phase C отработал: result=0 →
SUCCESS → `deploy → done`.
**Урок / ТЕХДОЛГ:** retag-стратегия BUILD-ONCE предполагает гарантию свежести staging-
образа, которой НЕТ. Нужна отдельная задача: либо staging-деплой пересобирает образ из
текущего main перед валидацией, либо deploy-хук проверяет, что staging-образ собран из
HEAD main (по labels/sha), иначе fail-fast. Сейчас «зелёный» деплой может молча катить
регресс. **Это самый опасный из четырёх — он не падает, а тихо откатывает прод.**
---
## Сквозной урок: bootstrap самохостинга
Любая задача, меняющая deploy/merge-механику САМОГО оркестратора, упирается в парадокс:
её механизм не работает, пока не в проде, а в прод его можно влить только старым
механизмом. Каждый слой (код → права → env → образ) вскрывается ТОЛЬКО при первом
реальном прогоне. Закладывать в план таких задач **ручной bootstrap-чеклист** и гонять
**реальный** деплой в staging-петле до мержа, а не только бумажные гейты.
## Прод после (main `115519e`+, образ 2026-06-07 09:47)
- self_deploy.py + reconciler.py в проде, finalizer зарегистрирован (grep=5)
- uid 1000 = slin (passwd ok), ssh slin@127.0.0.1 работает, /var/log/orchestrator writable
- ORCH-36 task 43 → done, Plane → Done

View File

@@ -8,6 +8,8 @@
1. **Захват текущего образа** — до рестарта записывает ID образа работающего контейнера в `$PREV_IMAGE_FILE` (best-effort, не падает если сервис не запущен).
2. **git pull** — обновляет код репозитория.
2b. **Build-once retag** (ORCH-036, BR-6) — если задан `$SOURCE_IMAGE`, хук ретегает его на `$TARGET_IMAGE` (`docker tag $SOURCE_IMAGE $TARGET_IMAGE`) и поднимает контейнер на этом образе через `up -d --no-build`. Это деплой РОВНО того образа, что прошёл staging, **без `docker build`**. Если `$SOURCE_IMAGE` не задан (дефолт) — шаг пропускается (обратная совместимость).
- **Fail-closed провенанс-guard** (ORCH-058, Strategy B) — ПЕРЕД `docker tag`, если задан `$EXPECTED_REVISION`, хук сверяет OCI-лейбл `org.opencontainers.image.revision` у `$SOURCE_IMAGE` с `$EXPECTED_REVISION`. Несовпадение / пустой лейбл (`<no value>`) / ошибка inspect → лог + `exit 1` (FAILED → авто-rollback), **прод не трогается**. Не задан `$EXPECTED_REVISION` (дефолт) → проверка пропускается (обратная совместимость для не-self репозиториев).
3. **Рестарт контейнера**`docker compose --profile $COMPOSE_PROFILE up -d --no-build $TARGET_SERVICE`.
4. **Health-цикл** — 10 попыток × 6с = до 60с. Критерий: HTTP 200 + тело содержит `"status":"ok"`.
- **Успех** → `exit 0`, лог "Deploy SUCCESS".
@@ -16,6 +18,17 @@
- Если восстановился → `exit 1` (деплой провалился, откат успешен).
- Если и откат не помог → `exit 2` (критично).
### Режим `--build-staging` (ORCH-058, Strategy A)
Пересобирает **staging-образ** из провалидированного коммита и пересоздаёт 8501, чтобы артефакт, который мы валидируем, был РОВНО тем, что позже build-once ретегается в прод (инвариант `INV-FRESH`). Собирает/пересоздаёт **только staging (8501)** — никогда прод (8500).
1. `docker build --build-arg GIT_SHA=$GIT_SHA -t $TARGET_IMAGE $BUILD_CONTEXT` — пересборка из host-worktree валидированного коммита; `GIT_SHA` штампуется в OCI-лейбл `org.opencontainers.image.revision`.
2. `docker compose [--profile $COMPOSE_PROFILE] up -d --no-build $TARGET_SERVICE` — пересоздание staging на свежем образе.
3. Health-цикл 10×6с. Провал сборки/health → `exit 1`.
4. **`staging_check` против СВЕЖЕГО образа** (Strategy A, шаг 3 — ADR-001, AC-4) — после health хук запускает `docker exec $STAGING_CONTAINER python3 $STAGING_CHECK_PATH --base-url http://localhost:$TARGET_PORT --mode $STAGING_CHECK_MODE` (дефолт `--mode stub`, без LLM-трат). Запуск **внутри** staging-контейнера канонический (ORCH-048): suite читает реестр из собственного env контейнера, а `staging_check.py` берётся из bind-mount (`/repos/orchestrator/scripts/...`, не из образа). Это ровно тот артефакт, что позже build-once ретегается в прод → валидируем то, что промоутим (AC-4). PASS → `exit 0`; любой не-ноль (FAIL чека или safety-abort `ORCH_STAGING≠true`) → `exit 1`.
Запускается оркестратором на ребре `deploy-staging → deploy` (QG-под-чек `check_staging_image_fresh``rebuild_staging_image` пробрасывает явный staging-таргет, см. `INFRA.md`). Тот же контракт кодов выхода (0 = здоров **и** staging_check PASS).
### Режим `--rollback`
Вручную откатывает сервис на предыдущий образ из `$PREV_IMAGE_FILE`.
@@ -29,6 +42,13 @@
| `TARGET_IMAGE` | `orchestrator-orchestrator-staging` | Имя образа для retag при rollback |
| `COMPOSE_PROFILE`| `staging` | Docker compose profile (пусто = без профиля) |
| `PREV_IMAGE_FILE`| `$REPO/.deploy-prev-image-staging`| Файл для сохранения предыдущего образа |
| `SOURCE_IMAGE` | _(unset)_ | Build-once (ORCH-036): провалидированный образ для retag на `$TARGET_IMAGE` перед рестартом (без rebuild). Не задан → шаг пропущен. |
| `EXPECTED_REVISION` | _(unset)_ | Build-once (ORCH-058, Strategy B): ожидаемый git-SHA `$SOURCE_IMAGE` (лейбл `org.opencontainers.image.revision`). Задан → fail-closed guard перед `docker tag`. Не задан → проверка пропущена. |
| `GIT_SHA` | _(unset)_ | `--build-staging` (ORCH-058, Strategy A): коммит, штампуемый в OCI-лейбл `revision` при пересборке staging-образа. |
| `BUILD_CONTEXT` | `$REPO` | `--build-staging`: docker build context (host-worktree валидированного коммита). |
| `STAGING_CONTAINER` | `$TARGET_SERVICE` (`orchestrator-staging`) | `--build-staging` (ORCH-058): контейнер, внутри которого `docker exec` запускает `staging_check`. |
| `STAGING_CHECK_PATH` | `/repos/orchestrator/scripts/staging_check.py` | `--build-staging` (ORCH-058): путь к `staging_check.py` внутри контейнера (bind-mount, не образ). |
| `STAGING_CHECK_MODE` | `stub` | `--build-staging` (ORCH-058): режим `staging_check` (`stub` — быстро, без LLM; `full-real` — дожидается аналитика). |
| `LOG` | `/var/log/orchestrator/deploy-hook.log` | Лог-файл (fallback: `$REPO/deploy-hook.log`) |
> ⚠️ **Дефолт — всегда STAGING**. Прод активируется только явным переопределением env.
@@ -55,6 +75,20 @@ PREV_IMAGE_FILE=/home/slin/repos/orchestrator/.deploy-prev-image-prod \
bash scripts/orchestrator-deploy-hook.sh --deploy
```
### Прод build-once (ORCH-036) — ретег staging-образа, без rebuild
Так прод-деплой запускается **автоматически** исполняемым самодеплоем (Фаза B: `ssh + setsid`, см. `INFRA.md`). Ключевое отличие — `SOURCE_IMAGE` указывает на провалидированный staging-образ, который ретегается на прод-тег:
```bash
SOURCE_IMAGE=orchestrator-orchestrator-staging \
TARGET_SERVICE=orchestrator \
TARGET_PORT=8500 \
TARGET_IMAGE=orchestrator-orchestrator \
COMPOSE_PROFILE="" \
PREV_IMAGE_FILE=/home/slin/repos/orchestrator/.deploy-prev-image-prod \
bash scripts/orchestrator-deploy-hook.sh --deploy
```
### Ручной rollback staging
```bash

View File

@@ -75,6 +75,16 @@ ADR `docs/work-items/ORCH-040/06-adr/ADR-001-run-agents-as-host-uid.md` и гл
| `ORCH_AGENT_EFFORT_DEFAULT` | режим работы `--effort` по умолчанию (ORCH-41): low\|medium\|high\|xhigh\|max; дефолт `high` |
| `ORCH_AGENT_EFFORT_<AGENT>` | per-agent effort; дефолт: думающие → high, tester/deployer → medium |
| `ORCH_AGENT_FALLBACK_MODEL` | опц. фолбэк-модель при overloaded (`--fallback-model`); пусто → без флага |
| `ORCH_SELF_DEPLOY_ENABLED` | ORCH-036 kill-switch исполняемого самодеплоя (true); false → legacy-путь для всех |
| `ORCH_SELF_DEPLOY_REPOS` | CSV репозиториев с реальным самодеплоем; пусто → только self-hosting `orchestrator` |
| `ORCH_DEPLOY_REQUIRE_MANUAL_APPROVE` | требовать человеческий Plane «Approved» для прод-деплоя (true, безопасно) |
| `ORCH_DEPLOY_FINALIZE_DELAY_S` / `_MAX_ATTEMPTS` | задержка и бюджет defer'ов finalizer'а (Фаза C; 90 / 10) |
| `ORCH_DEPLOY_SSH_USER` / `_SSH_HOST` | куда запускается detached хост-деплой (Фаза B, `ssh user@host`) |
| `ORCH_DEPLOY_HOOK_SCRIPT` / `_HOST_REPO_PATH` | путь к хук-скрипту (отн. репо) и чекаут orchestrator на хосте |
| `ORCH_DEPLOY_PROD_SOURCE_IMAGE` | staging-образ для build-once retag на прод-тег (без rebuild) |
| `ORCH_DEPLOY_PROD_TARGET_SERVICE` / `_TARGET_PORT` / `_TARGET_IMAGE` / `_COMPOSE_PROFILE` / `_PREV_IMAGE_FILE` | прод-цель хука + снапшот для авто-rollback |
| `ORCH_IMAGE_FRESHNESS_ENABLED` | ORCH-058 единый kill-switch провенанса staging-образа (A+B как целое); дефолт `true`, false → legacy build-once без проверки свежести |
| `ORCH_IMAGE_FRESHNESS_REPOS` | CSV репозиториев с реальным гейтом свежести; пусто → только self-hosting `orchestrator` |
| `ORCH_RECONCILE_ENABLED` | kill-switch sweeper потерянных webhook (ORCH-053); дефолт `true`. **При инциденте/раскатке**`false` глушит весь фоновый reconciler |
| `ORCH_RECONCILE_PLANE_ENABLED` | отдельный флаг F-2 (опрос Plane API); `false` гасит только plane-ветку, F-1 продолжает работать; дефолт `true` |
| `ORCH_RECONCILE_INTERVAL_S` | период фонового прохода reconciler, сек; дефолт `120` |
@@ -123,6 +133,7 @@ ADR `docs/work-items/ORCH-040/06-adr/ADR-001-run-agents-as-host-uid.md` и гл
**Страховки:**
- Стадия `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.
**Правила для агентов при задачах ORCH:**
1. НЕ перезапускать / не ронять прод-контейнер `orchestrator` в рамках задачи.

View File

@@ -75,6 +75,27 @@ completely invisible to commands that do not pass `--profile staging`.
docker logs -f orchestrator-staging
```
## Staging-образ как источник прод-артефакта (ORCH-058)
Прод-деплой орка — **build-once**: хук ретегает провалидированный staging-образ
(`orchestrator-orchestrator-staging`) на прод-тег **без rebuild** (ORCH-036). Чтобы
в прод не попал устаревший образ (инцидент LESSONS_ORCH-036 п.4), ORCH-058 гарантирует
свежесть staging-образа **двумя слоями** (только self-hosting):
- **A — пересборка staging (liveness):** на ребре `deploy-staging → deploy` (ПОСЛЕ
merge-gate, ДО Phase A) QG-под-чек `check_staging_image_fresh` через хук
`--build-staging` пересобирает staging-образ из worktree валидированного коммита
(`--build-arg GIT_SHA=<sha>`, OCI-лейбл `org.opencontainers.image.revision`) и
пересоздаёт 8501. Так валидируем РОВНО тот артефакт, что промоутится в прод.
FAIL → откат на `development`. Сборки/recreate — **только staging (8501)**.
- **B — fail-closed guard (safety):** прод-хук перед `docker tag` сверяет лейбл
`revision` у `SOURCE_IMAGE` с `EXPECTED_REVISION` (пробрасывает оркестратор);
несовпадение / пустой лейбл / ошибка inspect → `exit 1`, прод не трогается.
Kill-switch `ORCH_IMAGE_FRESHNESS_ENABLED` включает A+B **как целое**; область —
`ORCH_IMAGE_FRESHNESS_REPOS` (пусто → только `orchestrator`). Детали — `DEPLOY_HOOK.md`,
`docs/work-items/ORCH-058/06-adr/ADR-001-staging-image-provenance.md`.
## Roadmap
| Task | Description |

View File

@@ -0,0 +1,7 @@
# Business Request: ORCH-36: Исполняемый самодеплой — стадия deploy дёргает хост-хук (Вариант B)
Work Item ID: ORCH-036
## Description
TBD

View File

@@ -0,0 +1,109 @@
# BRD — ORCH-36: Исполняемый самодеплой (стадия deploy дёргает хост-хук, Вариант B)
Work Item: ORCH-036
Stage: analysis
Автор: analyst
Дата: 2026-06-06
## 1. Контекст и проблема
Стадия `deploy` конвейера сейчас **«бумажная»**. На ней deployer-агент (LLM) только
пишет `docs/work-items/<wi>/14-deploy-log.md` с `deploy_status: SUCCESS|FAILED`, а QG
`check_deploy_status` (`src/qg/checks.py:464`) парсит этот вердикт и пускает `deploy → done`.
**Реального docker-деплоя нет** — продакшен орка катается руками (Стрим).
Хост-хук `scripts/orchestrator-deploy-hook.sh` **уже существует** (ORCH-34) и умеет:
захват PREV_IMG → `git pull` → рестарт сервиса → health-check (10×6с = 60с) →
авто-rollback при провале health, с корректным exit-code. Дефолты — STAGING-безопасные;
прод включается через override env (`TARGET_SERVICE`, `TARGET_PORT`, `TARGET_IMAGE`,
`COMPOSE_PROFILE`).
**Главная мина (self-hosting):** прод-контейнер `orchestrator` (8500) — ОДИН на все
проекты, и в нём же бежит сам deployer-агент. Deployer не может синхронно рестартить
контейнер, в котором живёт (`docker compose up -d orchestrator` убьёт его процесс на
середине). Реальный рестарт self-репо обязан делать ВНЕШНИЙ хост-хук (вне контейнера),
который срабатывает ПОСЛЕ выхода агента. Рубильник — снаружи; орк только ИНИЦИИРУЕТ.
## 2. Цель
Превратить стадию `deploy` в РЕАЛЬНЫЙ самодеплой: после зелёного `deploy-staging`-гейта
конвейер вызывает хост-хук с прод-параметрами, хук промоутит образ в прод (8500) с
health-чеком и авто-rollback. Результат хука (exit-code) маппится в `deploy_status`.
**На старте — с ОБЯЗАТЕЛЬНЫМ ручным approve** (`DEPLOY_REQUIRE_MANUAL_APPROVE=true`):
прод не трогается без явного «go» Владельца.
## 3. Ценность для бизнеса
- Уходит последний ручной шаг конвейера (прод-деплой Стрим) → шаг к автономному внедрению (эпик ORCH-54).
- `deploy_status: SUCCESS` становится **доказанным** (реальный health-ok), а не декларацией LLM.
- Гарантия build-once: «что протестировали на staging — то и в проде» (тот же образ, без пересборки).
- Прод никогда не остаётся в нерабочем состоянии: авто-rollback + health-таймаут.
## 4. Заинтересованные стороны
| Роль | Интерес |
|------|---------|
| Владелец (Слава/Стрим) | Контроль через ручной approve; уведомления о каждом промоуте/откате |
| Проект enduro-trails | Прод-орк не должен падать (общий инстанс) — групповой риск |
| Конвейер ORCH | Стадия `deploy` исполняемая, гейты не сломаны |
## 5. Объём (scope)
### В объёме
1. Исполнение реального прод-деплоя из стадии `deploy` через хост-хук (ssh / detached на хосте).
2. Обязательный ручной approve-гейт ПОСЛЕ зелёного staging и ДО прод-рестарта (флаг включён).
3. Маппинг exit-code хука → `deploy_status: SUCCESS|FAILED` (реальный, не бумажный).
4. Уведомления (Plane-коммент + Telegram) на каждый промоут и откат.
5. Build-once: перетегирование образа, прошедшего staging, без пересборки.
6. Обновление `deployer.md` и `INFRA.md` (документация = golden source).
7. Для НЕ-self репо (enduro-trails и др.) — деплой по ssh на их хост (поведение не ломается).
### Вне объёма (явно)
- **Включение полного авто** (`DEPLOY_REQUIRE_MANUAL_APPROVE: true → false`) — отдельная задача,
ТОЛЬКО после набора метрик доверия (см. §7). В этой задаче флаг НЕ выключается.
- Изменение `docker-compose.yml` без явной необходимости.
- Изменение стадий `STAGE_TRANSITIONS`, реестра QG, terminal-sync `deploy → done`.
- Прод-деплой в реальный бой во время разработки задачи (отладка — только на staging-цели хука).
## 6. Бизнес-требования
- **BR-1.** После зелёного `deploy-staging`-гейта стадия `deploy` РЕАЛЬНО собирает/перетегирует
образ, рестартит целевой сервис и проверяет health — не пишет бумажный SUCCESS.
- **BR-2.** Для self-репо `orchestrator` рестарт 8500 выполняется ВНЕШНИМ (detached/host)
процессом; deployer-агент НЕ убивает контейнер, в котором работает.
- **BR-3.** `deploy_status: SUCCESS` пишется ТОЛЬКО при health-ok хука; провал/health-fail →
`deploy_status: FAILED` → откат на `development` (как ORCH-35 staging-rollback, БАГ-8).
- **BR-4.** Ручной approve обязателен (флаг `true`): без явного «go» прод НЕ трогается.
- **BR-5.** Каждый промоут и откат уведомляет Владельца: Plane-коммент в задачу + Telegram.
«Молчаливых» деплоев нет.
- **BR-6.** Build-once: в прод идёт тот образ, что прошёл staging-гейт (перетег, не пересборка).
- **BR-7.** Staging-гейт (`check_staging_status`) остаётся обязательным предусловием прод-деплоя.
- **BR-8.** Прод никогда не остаётся в нерабочем состоянии — авто-rollback при провале health.
- **BR-9.** Существующие гейты и инварианты не ломаются: `check_deploy_status`,
`_parse_deploy_status`, rollback `deploy → development` (БАГ-8), terminal-sync `deploy → done`,
merge-gate (ORCH-43).
- **BR-10.** Документация (`deployer.md`, `INFRA.md`, `CHANGELOG.md`) обновлена в том же PR.
## 7. Критерии готовности к включению ПОЛНОГО авто (вне этой задачи)
Переключать `DEPLOY_REQUIRE_MANUAL_APPROVE: true → false` можно ТОЛЬКО когда закрыты ВСЕ 5:
1. ≥10 успешных промоутов подряд (staging зелёный → approve → прод поднялся, откат не нужен).
2. Zero false-negative: staging-гейт ни разу не пропустил битый деплой как «зелёный».
3. Авто-rollback проверен в бою (≥23 реальных срабатывания), recovery 100%, MTTR < 60с.
4. Ни одного «молчаливого» деплоя (каждый промоут/откат уведомил Владельца).
5. Период наблюдения ≥10 деплоев ИЛИ ≥2 недели без инцидентов в режиме manual-approve.
## 8. Риски
| Риск | Влияние | Митигация |
|------|---------|-----------|
| Падение прод-орка 8500 при self-деплое | Встаёт конвейер ВСЕХ проектов | Detached host-хук + health + авто-rollback; отладка на staging-цели |
| Deployer рестартит сам себя синхронно | Процесс агента убит на середине | BR-2: рестарт только внешним detached-процессом |
| Преждевременный `deploy_status: SUCCESS` (хук ещё не закончил) | Задача уходит в done при незавершённом деплое | Гейт читает РЕАЛЬНЫЙ исход хука (механизм — на дизайне) |
| Деплой без approve | Неконтролируемый прод-деплой | BR-4: approve-гейт блокирует до «go» |
| Пересборка вместо перетега | В прод уезжает не то, что тестировали | BR-6: build-once, `--no-build` + retag |
## 9. Связанные задачи
ORCH-7 (self-hosting), ORCH-21 (auto-rollback), ORCH-34 (хук готов), ORCH-35 (staging-гейт),
ORCH-43 (merge-gate в проде), ORCH-54 (эпик автономного внедрения).
Дизайн-референс: `tasks/orchestrator/DESIGN_STAGING_ENV.md §4/§7`.

View File

@@ -0,0 +1,136 @@
# ТЗ — ORCH-36: Исполняемый самодеплой (стадия deploy дёргает хост-хук, Вариант B)
Work Item: ORCH-036
Stage: analysis
Автор: analyst
Дата: 2026-06-06
> Документ фиксирует ТРЕБОВАНИЯ к изменениям (что и где). Конкретный механизм
> (ssh vs docker.sock vs detached nohup/systemd-run; механизм approve) выбирает
> архитектор в ADR (`06-adr/`). ТЗ задаёт границы и контракты, не реализацию.
## 1. Текущее устройство (as-is, разведано в коде)
- **Стадии** (`src/stages.py`): `… testing → deploy-staging → deploy → done`.
- `deploy-staging`: `agent=deployer`, `qg=check_staging_status` (запускается deployer при
выходе из `deploy-staging`, входе в `deploy`).
- `deploy`: `agent=None`, `qg=check_deploy_status` (агент НЕ запускается при выходе из `deploy`).
- **Вывод:** реальную работу стадии `deploy` делает deployer-агент, запущенный на переходе
`deploy-staging → deploy`. Он пишет `14-deploy-log.md`. Когда он завершается, `advance_stage`
с `current_stage=deploy` прогоняет `check_deploy_status` и двигает `deploy → done`.
- **QG** (`src/qg/checks.py`):
- `check_deploy_status:464``_parse_deploy_status:406` читает ТОЛЬКО `deploy_status:` из
YAML-frontmatter `14-deploy-log.md` (worktree → origin/main fallback → not found).
- `check_staging_status:580` — условный (реален только для self-hosting `orchestrator`).
- `is_self_hosting_repo()` (`:511`) — детектор self-репо.
- **Откаты/диспетчеризация** (`src/stage_engine.py`):
- `_handle_qg_failure_rollbacks:585` — ветка `deployer` + `check_deploy_status` FAILED →
откат `deploy → development`, `set_issue_blocked`, release merge-lease, Plane+Telegram.
- Terminal-sync `deploy → done` (`:281`) → `set_issue_done`, release merge-lease.
- merge-gate (ORCH-43) на ребре `deploy-staging → deploy`НЕ трогать.
- **Launcher** (`src/agents/launcher.py`):
- deployer-агент конфиг: `.task-deploy.md` / `.openclaw/agents/deployer.md` (`:180`).
- Пост-обработка: commit+push артефактов в worktree (`:506-558`).
- `exit_code != 0 && agent == deployer` → откат `deploy → development` (`:560-581`).
- **Хост-хук** (`scripts/orchestrator-deploy-hook.sh`, ORCH-34) — ГОТОВ: `--deploy`/`--rollback`,
параметризован env, дефолты STAGING; health 10×6с; авто-rollback; exit 0/1/2.
- **Agent (deployer.md)**: на стадии `deploy` сейчас пишет «бумажный» вердикт; в промпте маркер
«Real docker/SSH deploys are handled by scripts/orchestrator-deploy-hook.sh (ORCH-36)».
- **Топология** (`docs/operations/INFRA.md`): prod=8500 (`.env`), staging=8501 (`.env.staging`,
profile staging). Контейнер под uid 1000, доступ к docker.sock через gid 999.
## 2. Изменения по модулям (to-be)
### 2.1 `scripts/orchestrator-deploy-hook.sh` (донастройка прод-режима)
- Хук уже параметризован; требуется обеспечить **корректный прод-профиль вызова**:
`TARGET_SERVICE=orchestrator`, `TARGET_PORT=8500`, `TARGET_IMAGE=orchestrator-orchestrator`,
`COMPOSE_PROFILE` (для прод-сервиса — пустой/дефолтный, т.к. prod стартует без profile).
- **Build-once (BR-6):** деплой должен использовать образ, прошедший staging (перетег
staging-образа → прод-тег + `docker compose up -d --no-build`), а НЕ пересобирать. Если
текущий хук всегда `--no-build` и тянет `git pull` — уточнить в ADR, как гарантируется
идентичность артефакта staging↔prod (retag staging image, либо общий build-once шаг).
- `PREV_IMAGE_FILE` для прод — отдельный путь (например `.deploy-prev-image` без `-staging`),
чтобы не путать снапшоты prod/staging.
- Поведение `--rollback`, health-loop, exit-code (0=ok, 1=rolled back, 2=rollback тоже упал) —
НЕ менять контракт.
### 2.2 Approve-гейт (новое; место — на дизайне)
- Ввести флаг конфигурации `DEPLOY_REQUIRE_MANUAL_APPROVE` (bool, дефолт `true`).
- При `true`: перед вызовом прод-хука (после зелёного `deploy-staging`) конвейер ОСТАНАВЛИВАЕТСЯ
и ждёт явного «go» Владельца. Без «go» прод-хук НЕ вызывается.
- Механизм approve (выбрать ОДИН в ADR): Plane-коммент-триггер (по образцу `:approved:`
в `check_analysis_approved`) / Telegram-кнопка / signal-файл. Требование к механизму:
рестарт-safe (переживает перезапуск инстанса), идемпотентный, аудируемый.
- При `false` (вне этой задачи): approve-шаг пропускается — НЕ реализовывать выключение здесь,
только заложить ветку по флагу.
### 2.3 Триггер реального деплоя из стадии `deploy`
- На стадии `deploy` (для self-репо `orchestrator`) вместо/в дополнение к записи вердикта
агентом — ИНИЦИИРОВАТЬ внешний detached-процесс (host-хук), который выполнит
build-once+restart+health ПОСЛЕ выхода агента (BR-2: агент не рестартит сам себя).
- Маршрут вызова (на дизайне): ssh на хост (`DEPLOY_SSH_USER`/`DEPLOY_HOOK_SCRIPT`) ИЛИ
detached через docker.sock/nohup/systemd-run. Требование: процесс хука переживает выход
агента и завершение его сессии.
- Для **не-self** репо (enduro-trails): деплой по ssh на их хост (как раньше) — поведение не ломать.
### 2.4 Маппинг результата хука → `deploy_status`
- `deploy_status: SUCCESS` пишется в `14-deploy-log.md` ТОЛЬКО при exit-code хука = 0 (health-ok).
- exit-code ≠ 0 (1 = rolled back; 2 = rollback тоже упал) → `deploy_status: FAILED`.
- **Контракт `_parse_deploy_status` НЕ меняется** (читает `deploy_status: SUCCESS|FAILED` из
frontmatter). Меняется только КТО и КОГДА пишет этот вердикт — на основе реального исхода.
- **Гонка чтения гейта:** т.к. self-рестарт асинхронный (detached), гейт `check_deploy_status`
не должен прочитать вердикт ДО завершения хука. Механизм синхронизации (post-factum запись
лога/мердж в main / отложенный гейт) — спроектировать в ADR так, чтобы гейт читал РЕАЛЬНЫЙ
итог. Контракт чтения из worktree→origin/main (`_deploy_log_from_main`) можно переиспользовать.
### 2.5 Уведомления (BR-5)
- На промоут (старт прод-деплоя + успех) и на откат → `plane_add_comment(work_item_id, …)` +
`send_telegram(…)`. Переиспользовать существующие хелперы (`src/notifications.py`,
`src/plane_sync.py`). Никаких «молчаливых» деплоев.
### 2.6 Конфигурация (`src/config.py` / `.env.example` / `.env.staging.example`)
- Новый: `deploy_require_manual_approve: bool = True` (env `ORCH_DEPLOY_REQUIRE_MANUAL_APPROVE`).
- Прод-параметры хука: `DEPLOY_SSH_USER`, `DEPLOY_SSH_HOST`, `DEPLOY_HOOK_SCRIPT` (уже есть в
INFRA-карте) + прод-override `TARGET_SERVICE/PORT/IMAGE`. Прописать дескрипторы в `.env.example`
(значения — только на хосте, не коммитить).
- Условность по репо: реальный прод-деплой — только для self-hosting (`is_self_hosting_repo`),
как ORCH-35; прочие репо идут прежним ssh-путём.
### 2.7 Документация (BR-10, golden source)
- `.openclaw/agents/deployer.md` — раздел «Stage: deploy»: переписать с «бумажного SUCCESS» на
«стадия ВЫЗЫВАЕТ хук»; зафиксировать запрет синхронного рестарта 8500 и detached-путь self.
- `docs/operations/INFRA.md` — процедура прод-деплоя орка через хук + approve.
- `docs/operations/DEPLOY_HOOK.md` — обновить, если затронут контракт хука.
- `CHANGELOG.md` — запись о включении исполняемого деплоя (manual-approve).
- ADR в `docs/work-items/ORCH-036/06-adr/ADR-NNN-*.md` (создаёт архитектор).
## 3. API
- Изменений публичного HTTP API (`/health`, `/status`, `/queue`, `/webhook/*`) **не требуется**.
- Если approve реализуется через Plane-коммент — переиспользуется существующий webhook-путь
(`POST /webhook/plane`), новый endpoint не вводится. Если через signal-файл/Telegram —
внешний по отношению к HTTP API механизм. Решение — ADR.
## 4. Схема БД
- Изменения схемы **не требуются** для базового сценария (вердикт — в `14-deploy-log.md`;
approve-состояние желательно хранить рестарт-safe — допустимо через jobs/task_content или
signal-файл, без новой таблицы). Если архитектор сочтёт нужным поле статуса approve —
обосновать в ADR; по умолчанию — без миграции.
## 5. Требования к Quality Gates
- `check_deploy_status` и `_parse_deploy_status` — контракт чтения НЕ менять (frontmatter only).
- Откат `deploy → development` при `deploy_status: FAILED` (`stage_engine` БАГ-8) — сохранить.
- Terminal-sync `deploy → done` и release merge-lease — сохранить.
- merge-gate (`check_branch_mergeable`) на ребре `deploy-staging → deploy` — не затрагивать.
- `check_staging_status` остаётся обязательным предусловием (BR-7).
## 6. Артефакты pipeline
- Создаётся/обновляется: `docs/work-items/ORCH-036/14-deploy-log.md` (с РЕАЛЬНЫМ `deploy_status`).
- Обновляются по pipeline: `06-adr/ADR-NNN-*.md`, `12-review.md`, `13-test-report.md`,
`15-staging-log.md` (последующими агентами).
## 7. Нефункциональные требования
- **Безопасность self-deploy:** рестарт 8500 — только внешним рубильником; орк не может
необратимо убить себя.
- **Идемпотентность** хука и approve-механизма; **рестарт-safe** approve-состояние.
- **MTTR < 60с** при авто-rollback (health-loop хука 10×6с уже укладывается).
- **Отладка только на staging-цели** хука; реальный прод — лишь после approve.

View File

@@ -0,0 +1,97 @@
# Критерии приёмки — ORCH-36: Исполняемый самодеплой (Вариант B)
Work Item: ORCH-036
Stage: analysis
Автор: analyst
Дата: 2026-06-06
Формат: каждый критерий — проверяемое условие PASS/FAIL. Отладка и проверки
выполняются на **staging-цели хука** (8501); реальный прод (8500) — только после approve.
---
## AC-1. Стадия deploy исполняет реальный деплой (не бумажный)
- **PASS:** на стадии `deploy` (после зелёного `deploy-staging`) вызывается хост-хук,
который реально перетегирует образ, рестартит целевой сервис и выполняет health-check;
`deploy_status` отражает РЕАЛЬНЫЙ исход хука.
- **FAIL:** `deploy_status: SUCCESS` пишется без фактического рестарта/health (бумажный лог).
- **Проверка:** прогон на staging-цели хука; в логе хука видны retag + `up -d` + health-loop;
exit-code хука соответствует записанному `deploy_status`.
## AC-2. Self-репо: рестарт 8500 — внешним detached-процессом, агент себя не убивает
- **PASS:** для `orchestrator` рестарт 8500 выполняет процесс ВНЕ контейнера агента; deployer-агент
завершается штатно (exit 0), его процесс не убит рестартом контейнера.
- **FAIL:** deployer синхронно делает `docker compose up -d orchestrator` из контейнера и/или
агент падает/обрывается на середине из-за рестарта собственного контейнера.
- **Проверка:** симуляция на staging-цели; убедиться, что detached-процесс переживает выход агента.
## AC-3. deploy_status маппится из exit-code хука
- **PASS:** exit-code хука 0 → `deploy_status: SUCCESS`; exit-code ≠ 0 (1/2) → `deploy_status: FAILED`.
- **FAIL:** любой иной маппинг (например SUCCESS при exit 1).
- **Проверка:** unit-тест маппинга exit-code → вердикт; интеграционный прогон с искусственным
кодом возврата хука.
## AC-4. Провал деплоя → откат на development
- **PASS:** при `deploy_status: FAILED` задача откатывается `deploy → development`
(`set_issue_blocked`, Plane+Telegram), как в существующей ветке БАГ-8.
- **FAIL:** при FAILED задача уходит в `done` или зависает.
- **Проверка:** существующий контракт `stage_engine._handle_qg_failure_rollbacks` для
`deployer`+`check_deploy_status` сохранён и срабатывает.
## AC-5. Ручной approve обязателен и реально тормозит прод
- **PASS:** при `DEPLOY_REQUIRE_MANUAL_APPROVE=true` прод-хук НЕ вызывается до явного «go»;
после «go» — вызывается.
- **FAIL:** прод-хук дёргается без approve.
- **Проверка:** прогон без «go» — целевой сервис НЕ перезапущен (нет записи рестарта в логе хука,
не сменился uptime/контейнер); прогон с «go» — рестарт состоялся.
## AC-6. Уведомления о каждом промоуте и откате
- **PASS:** на старт/успех прод-деплоя и на откат приходят и Plane-коммент в задачу, и Telegram.
- **FAIL:** хотя бы один промоут/откат прошёл «молчаливо».
- **Проверка:** в Plane-задаче и в Telegram-чате присутствуют сообщения для каждого исхода.
## AC-7. Build-once: в прод идёт образ, прошедший staging
- **PASS:** прод-деплой использует тот же образ, что прошёл staging-гейт (retag + `--no-build`),
без пересборки.
- **FAIL:** прод-деплой пересобирает образ заново (артефакт может отличаться от протестированного).
- **Проверка:** sha/тег образа прод == образ, валидированный на staging; в логе нет `build`.
## AC-8. Staging-гейт остаётся обязательным предусловием
- **PASS:** прод-деплой недостижим без зелёного `check_staging_status` (`staging_status: SUCCESS`).
- **FAIL:** прод-хук можно вызвать при FAILED/отсутствующем staging-вердикте.
- **Проверка:** при `staging_status: FAILED` задача откатывается на development, до `deploy` не доходит.
## AC-9. Авто-rollback восстанавливает прод (симуляция битого деплоя)
- **PASS:** при симуляции битого деплоя на staging-цели health не проходит → хук авто-откатывает
на предыдущий образ → сервис снова healthy; exit-code = 1 (rolled back); MTTR < 60с.
- **FAIL:** сервис остаётся нерабочим после провала деплоя.
- **Проверка:** искусственно сломать health, прогнать хук, убедиться в восстановлении и exit 1.
## AC-10. Существующие инварианты не сломаны
- **PASS:** не изменены контракты `check_deploy_status` / `_parse_deploy_status`,
`STAGE_TRANSITIONS`, terminal-sync `deploy → done`, merge-gate (ORCH-43), rollback БАГ-8.
- **FAIL:** любой из перечисленных контрактов изменён/сломан.
- **Проверка:** существующие тесты deploy/staging/merge-gate зелёные; регресс-прогон `pytest tests/`.
## AC-11. Условность по репо (не-self не ломается)
- **PASS:** для не-self репо (enduro-trails) деплой идёт прежним ssh-путём; self-логика (detached,
approve, 8500) применяется только для `orchestrator`.
- **FAIL:** не-self репо затронуты self-специфичной логикой и ломаются.
- **Проверка:** `is_self_hosting_repo` корректно разводит пути; тест на не-self репо.
## AC-12. Флаг полного авто НЕ выключен в этой задаче
- **PASS:** `DEPLOY_REQUIRE_MANUAL_APPROVE` остаётся `true`; переключение в `false` не делается.
- **FAIL:** флаг выставлен в `false` в рамках задачи.
- **Проверка:** дефолт конфигурации = `true`; в коде/`.env.example` нет принудительного `false`.
## AC-13. Документация обновлена (golden source)
- **PASS:** обновлены `deployer.md` (стадия deploy = вызов хука), `INFRA.md` (процедура),
`CHANGELOG.md`; заведён ADR в `06-adr/`.
- **FAIL:** функционал изменён, документация — нет (Reviewer обязан вернуть REQUEST_CHANGES).
- **Проверка:** диффы документации присутствуют в том же PR.
---
## Definition of Done
Все AC-1…AC-13 в статусе PASS; `pytest tests/` зелёный; артефакты pipeline на месте;
прод (8500) во время разработки НЕ тронут (вся проверка — на staging-цели хука).

View File

@@ -0,0 +1,122 @@
work_item: ORCH-036
title: "Исполняемый самодеплой — стадия deploy дёргает хост-хук (Вариант B)"
stage: analysis
notes: >
Все тесты — на изолированном уровне (unit/integration с моками subprocess/ssh
и хука). Реальный прод (8500) НЕ трогается. Интеграционные прогоны хука — на
staging-цели. Хост-хук (bash) проверяется отдельным интеграционным сценарием с
поддельным health/exit-code; в pytest вызов хука мокается.
tests:
# --- exit-code -> deploy_status mapping (AC-1, AC-3) ---
- id: TC-01
type: unit
description: "Маппинг exit-code хука 0 -> deploy_status: SUCCESS"
module: tests/test_deploy_hook_mapping.py
expected: PASS
- id: TC-02
type: unit
description: "Маппинг exit-code хука 1 (rolled back) -> deploy_status: FAILED"
module: tests/test_deploy_hook_mapping.py
expected: PASS
- id: TC-03
type: unit
description: "Маппинг exit-code хука 2 (rollback тоже упал) -> deploy_status: FAILED"
module: tests/test_deploy_hook_mapping.py
expected: PASS
# --- approve gate (AC-5, AC-12) ---
- id: TC-04
type: unit
description: "DEPLOY_REQUIRE_MANUAL_APPROVE дефолт == true в settings"
module: tests/test_deploy_approve.py
expected: PASS
- id: TC-05
type: integration
description: "Флаг true и нет 'go' -> прод-хук НЕ вызывается (subprocess/ssh не дёрнут)"
module: tests/test_deploy_approve.py
expected: PASS
- id: TC-06
type: integration
description: "Флаг true и есть 'go' -> прод-хук вызывается ровно один раз"
module: tests/test_deploy_approve.py
expected: PASS
# --- self vs non-self routing (AC-2, AC-11) ---
- id: TC-07
type: unit
description: "is_self_hosting_repo('orchestrator') == True; иной репо -> False (не регрессировал)"
module: tests/test_deploy_routing.py
expected: PASS
- id: TC-08
type: integration
description: "self-репо orchestrator: рестарт инициируется detached/host-процессом, не синхронно из агента"
module: tests/test_deploy_routing.py
expected: PASS
- id: TC-09
type: integration
description: "не-self репо (enduro-trails): деплой идёт прежним ssh-путём, self-логика не применяется"
module: tests/test_deploy_routing.py
expected: PASS
# --- rollback on FAILED (AC-4) ---
- id: TC-10
type: integration
description: "deploy_status: FAILED -> откат deploy->development, set_issue_blocked, release merge-lease"
module: tests/test_deploy_rollback.py
expected: PASS
# --- staging precondition preserved (AC-8) ---
- id: TC-11
type: integration
description: "staging_status: FAILED -> до стадии deploy не доходит (откат на development)"
module: tests/test_staging_precondition.py
expected: PASS
# --- notifications (AC-6) ---
- id: TC-12
type: integration
description: "Успешный промоут -> и Plane-коммент, и Telegram отправлены"
module: tests/test_deploy_notifications.py
expected: PASS
- id: TC-13
type: integration
description: "Откат -> и Plane-коммент, и Telegram отправлены (нет молчаливого деплоя)"
module: tests/test_deploy_notifications.py
expected: PASS
# --- build-once (AC-7) ---
- id: TC-14
type: integration
description: "Прод-деплой использует образ staging (retag, без build) — нет шага docker build"
module: tests/test_deploy_build_once.py
expected: PASS
# --- regression: unchanged gate contracts (AC-10) ---
- id: TC-15
type: unit
description: "_parse_deploy_status: SUCCESS->(True), FAILED->(False), нет frontmatter->(False) — контракт цел"
module: tests/test_qg_checks.py
expected: PASS
- id: TC-16
type: unit
description: "STAGE_TRANSITIONS deploy->done и agent/qg deploy не изменены"
module: tests/test_stages.py
expected: PASS
- id: TC-17
type: integration
description: "terminal-sync deploy->done (set_issue_done + release merge-lease) сохранён"
module: tests/test_deploy_terminal_sync.py
expected: PASS
- id: TC-18
type: integration
description: "merge-gate на ребре deploy-staging->deploy не затронут (регресс ORCH-43 зелёный)"
module: tests/test_merge_gate.py
expected: PASS
# --- auto-rollback hook behavior (AC-9) ---
- id: TC-19
type: integration
description: "Симуляция битого деплоя на staging-цели: health fail -> авто-rollback -> healthy, exit 1, MTTR<60с"
module: tests/test_deploy_hook_rollback_sim.py
expected: PASS

View File

@@ -0,0 +1,184 @@
# ADR-001: Исполняемый самодеплой — стадия `deploy` дёргает хост-хук (Вариант B)
Work Item: ORCH-036
Stage: architecture
Автор: architect
Дата: 2026-06-06
## Статус
Accepted
## Контекст
Стадия `deploy` сейчас «бумажная»: deployer-агент (LLM) пишет в `14-deploy-log.md`
`deploy_status: SUCCESS|FAILED`, а гейт `check_deploy_status` (`src/qg/checks.py:464`)
парсит этот вердикт и двигает `deploy → done`. Реального docker-деплоя нет (прод
катается руками). BRD ORCH-36 требует превратить стадию в РЕАЛЬНЫЙ самодеплой с
обязательным ручным approve, build-once и авто-rollback (BR-1…BR-10).
Три твёрдых ограничения, разведанных в коде, определяют дизайн:
1. **Self-restart (BR-2).** Прод-контейнер `orchestrator` (8500) — ОДИН на все
проекты, и в нём же исполняется deployer. `docker compose up -d orchestrator`
из контейнера убьёт процесс агента/воркера на середине. Реальный рестарт обязан
делать ВНЕШНИЙ процесс на хосте, переживающий гибель контейнера.
2. **Status-only verdict model.** Комментарии Plane НЕ управляют конвейером —
механизм `:approved:`/`:rejected:` был удалён (`src/webhooks/plane.py:544`,
bug-3 «echo self-hit»). Единственный человеческий гейт — **смена статуса Plane
на `Approved`** (`handle_verdict``_try_advance_stage``advance_stage`).
3. **Гонка чтения гейта.** Так как реальный рестарт асинхронный и убивает контейнер,
`check_deploy_status` нельзя выполнять на выходе агента — вердикта ещё нет; его
преждевременное чтение → ложный FAILED → ложный откат.
Контракты, которые НЕ меняются (BR-9, AC-10): `STAGE_TRANSITIONS`,
`check_deploy_status` / `_parse_deploy_status` (frontmatter only), откат БАГ-8
(`deploy → development`), terminal-sync `deploy → done`, merge-gate (ORCH-43),
exit-code-контракт хука (0/1/2).
## Решение
Деплой стадии `deploy` для self-hosting (`orchestrator`) разбивается на **три фазы**,
оркеструемые детерминированным кодом (без LLM в критическом пути self-restart). Для
НЕ-self репо (enduro-trails и пр.) поведение НЕ меняется — прежний синхронный
ssh-деплой агентом.
### Условность по репо
Вся новая логика гейтится `is_self_hosting_repo(repo)` (как ORCH-35). Не-self репо
идут существующим путём: deployer-агент на стадии `deploy` делает ssh-деплой
синхронно, пишет `14-deploy-log.md`, гейт срабатывает на выходе агента.
### Фаза A — запрос approve (вход в `deploy`)
В `advance_stage` на ребре `deploy-staging → deploy` (ПОСЛЕ зелёного
`check_staging_status` и merge-gate ORCH-43), для self-hosting + `deploy_require_
manual_approve=true`:
- **НЕ** ставить в очередь прод-deployer (перехватить штатный
`enqueue_job(get_agent_for_stage("deploy-staging"))`);
- выставить issue в approval-pending статус (паттерн `set_issue_in_review`),
написать Plane-коммент «approve для прод-деплоя» + Telegram (BR-5);
- записать restart-safe маркер `approve-requested` (sentinel-файл, см. ниже).
Задача остаётся на стадии `deploy` и ждёт человека. `STAGE_TRANSITIONS` не меняется.
При `deploy_require_manual_approve=false` (вне объёма, флаг НЕ выключается в ORCH-36 —
AC-12) Фаза A сразу переходит к Фазе B без человеческого гейта. Структурная ветка
закладывается, но дефолт `true`.
### Фаза B — инициация деплоя (смена статуса Plane → Approved)
Человек ставит issue в `Approved`. `handle_verdict(approved=True)`
`_try_advance_stage``advance_stage(current_stage="deploy", finished_agent=None)`.
Новая ветка-перехват в `advance_stage`:
- условие: `current_stage=="deploy"` И `finished_agent is None` (человеческий путь)
И self-hosting И approve-флаг И маркер `initiated` ОТСУТСТВУЕТ;
- действие: запустить **внешний detached host-процесс** (см. ниже) и поставить в
очередь детерминированный **finalizer-job** с задержкой; записать маркер
`initiated` (идемпотентность: повторный Approved не запускает деплой дважды);
Plane-коммент «прод-деплой стартовал» + Telegram (BR-5);
- **вернуться БЕЗ advance** (НЕ запускать `check_deploy_status` — вердикта ещё нет).
Дискриминатор `finished_agent` разводит Фазу B (человек, `None`) и Фазу C
(finalizer, `"deployer"`), поэтому повторное использование `advance_stage` безопасно.
### Фаза C — фиксация вердикта (детерминированный finalizer)
Finalizer-job (claim'ится воркером уже в НОВОМ контейнере после рестарта):
- читает sentinel `result` (exit-code хука, записан host-процессом);
- если `result` ещё нет и бюджет попыток не исчерпан → **defer** (повторный
finalizer-job с `available_at_delay_s`, как merge-gate defer); бюджет считается
из `jobs` (`LIKE '%deploy-finalize%'`, restart-safe);
- если `result` есть → **маппинг exit-code → deploy_status** (детерминированный,
unit-тестируемый): `0 → SUCCESS`, `1|2|иное → FAILED`; записать
`14-deploy-log.md` (frontmatter `deploy_status:`), смержить в `main` (паттерн
лога), затем вызвать `advance_stage(current_stage="deploy", finished_agent="deployer")`;
- далее срабатывают СУЩЕСТВУЮЩИЕ контракты: `SUCCESS` → terminal-sync `deploy → done`
+ release merge-lease; `FAILED` → откат БАГ-8 `deploy → development` +
`set_issue_blocked` + Plane/Telegram (BR-3, AC-4). `_parse_deploy_status` НЕ меняется.
### Механизм detached-запуска: ssh + setsid
Выбор: **ssh на хост (`slin@DEPLOY_SSH_HOST`) с setsid-detached исполнением** хука.
Обоснование: ssh-ключи уже смонтированы (INFRA P-2), не-self репо уже деплоятся по
ssh (единый путь), хук живёт на хосте и под `slin` имеет полный доступ к docker вне
контейнера → переживает рестарт 8500 (BR-2). `setsid`/`nohup` + redirect отвязывает
удалённый процесс от ssh-канала, чтобы он пережил гибель ssh-клиента при рестарте
контейнера. Отвергнуто: вызов через docker.sock изнутри контейнера = ровно мина
«убей себя на середине вызова».
Эскиз (точная сборка — за разработчиком):
```
ssh -o StrictHostKeyChecking=no slin@$DEPLOY_SSH_HOST \
"setsid bash -c 'cd /home/slin/repos/orchestrator && \
SOURCE_IMAGE=orchestrator-orchestrator-staging \
TARGET_SERVICE=orchestrator TARGET_PORT=8500 \
TARGET_IMAGE=orchestrator-orchestrator COMPOSE_PROFILE= \
PREV_IMAGE_FILE=.deploy-prev-image-prod \
bash scripts/orchestrator-deploy-hook.sh --deploy; \
echo \$? > <result-sentinel>' >> <hook.log> 2>&1 </dev/null &"
```
ssh-команда возвращается сразу; remote-процесс detached. Запись sentinel `result`
делает **обёртка** (`echo $? > result`), а НЕ хук — контракт хука нетронут.
### Build-once (BR-6, AC-7)
Прод обязан подняться на ОБРАЗЕ, прошедшем staging (а не на пересборке). Решение:
расширить хук **опциональным** `SOURCE_IMAGE` (обратно совместимо: не задан →
текущее поведение). При заданном `SOURCE_IMAGE` хук ПЕРЕД `up -d --no-build`
делает `docker tag $SOURCE_IMAGE $TARGET_IMAGE`. Для прод-self:
`SOURCE_IMAGE=orchestrator-orchestrator-staging``TARGET_IMAGE=orchestrator-orchestrator`.
Это единственное допустимое изменение хука; exit-code-контракт и дефолтное
staging-поведение не меняются. `git pull` хука обновляет рабочее дерево хоста для
будущих сборок, но РАЗВЁРНУТЫЙ артефакт = перетегированный staging-образ.
### Restart-safe состояние: sentinel-файлы (без миграции БД)
По образцу merge-lease (`<repos_dir>/.merge-lease-<repo>.json`) состояние деплоя
хранится в файлах под `<repos_dir>/.deploy-state-<repo>/<work_item_id>/` (вне git,
видны и хосту, и контейнеру через mount `/home/slin/repos ↔ /repos`):
- `approve-requested` — Фаза A выполнена;
- `initiated` — Фаза B запущена (idempotency-guard);
- `result` — exit-code хука (пишет host-обёртка).
Бюджет finalize-defer считается из `jobs` (restart-safe), новых таблиц/колонок НЕТ
(TRZ §4).
## Последствия
### Плюсы
- `deploy_status: SUCCESS` становится ДОКАЗАННЫМ (реальный health-ok хука), не
декларацией LLM (BR-1).
- Self-restart безопасен: рестарт 8500 делает внешний host-процесс; орк себя не
убивает (BR-2). Вердикт фиксирует НОВЫЙ контейнер после рестарта.
- Критический путь self-restart **детерминирован** (без LLM) — главный выигрыш по
безопасности self-hosting; зеркалит детерминизм merge-gate ORCH-43.
- Approve вписан в существующую status-only модель — restart-safe, аудируемо в Plane,
идемпотентно (маркер `initiated`).
- Гонка чтения гейта закрыта: гейт читает РЕАЛЬНЫЙ итог через finalizer-defer.
- Build-once гарантирует «что тестировали — то в проде».
- Нетронуты: `STAGE_TRANSITIONS`, реестр QG, `_parse_deploy_status`, БАГ-8,
terminal-sync, merge-gate, контракт хука (exit-code).
### Минусы / ограничения
- Вводится **новый детерминированный job-handler** в очереди (reserved-agent
`deploy-finalizer`, не-LLM) — расширение dispatch воркера/лаунчера. Контейнированное,
но это новая под-компонента → задача помечается `arch:major-change`.
- Перехваты в `advance_stage` усложняют стадию `deploy` (три ветки по
`finished_agent`/маркерам). Требуется аккуратное покрытие тестами (TC-04…TC-09).
- Build-once зависит от того, что deploy-staging оставил валидный образ
`orchestrator-orchestrator-staging`; при rebase merge-gate возможен дрейф
образ↔main (см. 10-tech-risks R-3).
- Approve = смена статуса Plane на `Approved`; человек должен понимать, что на
стадии `deploy` `Approved` означает «деплой в прод» (документируется в deployer.md
и INFRA.md).
### Что обязан сделать developer
1. `src/config.py`: `deploy_require_manual_approve: bool = True` + прод-параметры
хука/ssh + `deploy_finalize_delay_s` / `deploy_finalize_max_attempts`.
2. `src/stage_engine.py`: перехваты Фазы A/B + ветка finalizer (Фаза C через
`advance_stage(..., finished_agent="deployer")`).
3. Очередь: reserved-agent `deploy-finalizer` (детерминированный handler:
read-result | defer | map+write+advance). Маппинг exit→status — отдельная
чистая функция (unit TC-01/02/03).
4. `scripts/orchestrator-deploy-hook.sh`: опциональный `SOURCE_IMAGE` retag
(обратно совместимо) + прод `PREV_IMAGE_FILE`.
5. Уведомления (Plane+Telegram) на initiate/success/rollback (BR-5).
6. Документация: `deployer.md`, `INFRA.md`, `DEPLOY_HOOK.md`, `CHANGELOG.md`.
7. Отладка — только на staging-цели хука; прод 8500 в разработке не трогать.
## Связанные решения
- Глобальный ADR: `docs/architecture/adr/adr-0007-executable-self-deploy.md`.
- ORCH-35 staging-gate (`adr-0003`), ORCH-43 merge-gate (`adr-0006`),
ORCH-21 auto-rollback, ORCH-34 хук, ORCH-40 run-as-host-uid (`adr-0005`).

View File

@@ -0,0 +1,48 @@
# Инфраструктурные требования — ORCH-036
Work Item: ORCH-036
Stage: architecture
Автор: architect
> Топология не меняется (та же mva154, те же два контейнера). Меняется ПРОЦЕДУРА
> прод-деплоя орка: из ручной → исполняемая через хост-хук с ручным approve.
## 1. Контейнеры / порты — без изменений
- prod `orchestrator` (8500), staging `orchestrator-staging` (8501) — как в INFRA.md.
- Образы (имена для build-once): prod `orchestrator-orchestrator`,
staging `orchestrator-orchestrator-staging`.
## 2. Хост-предусловия (Owner, в git не коммитятся)
- **HP-1.** ssh-доступ из контейнера на хост: `ssh slin@$DEPLOY_SSH_HOST` работает
под uid 1000 ключом из `~/.orchestrator-ssh` (INFRA P-2). Без него detached-запуск
Фазы B невозможен.
- **HP-2.** `<repos_dir>/.deploy-state-<repo>/` доступен на запись и хосту (host-обёртка
пишет `result`), и контейнеру (finalizer читает) — обеспечивается mount
`/home/slin/repos ↔ /repos` (как merge-lease).
- **HP-3.** `PREV_IMAGE_FILE` для прод — отдельный путь
(`.deploy-prev-image-prod`), чтобы не путать снапшоты prod/staging.
- **HP-4 (P-4 из INFRA).** Прод-рестарт self — только в окно тишины; общий инстанс
с enduro-trails. На старте — под ручным approve (флаг `true`).
## 3. Переменные окружения (карта; значения — на хосте, в git только дескрипторы)
| Переменная | Назначение | Дефолт |
|-----------|-----------|--------|
| `ORCH_DEPLOY_REQUIRE_MANUAL_APPROVE` | ручной approve перед прод-деплоем | `true` |
| `DEPLOY_SSH_USER` / `DEPLOY_SSH_HOST` | ssh-цель хост-хука | — (INFRA-карта) |
| `DEPLOY_HOOK_SCRIPT` | путь к хуку на хосте | `scripts/orchestrator-deploy-hook.sh` |
| прод `TARGET_SERVICE/PORT/IMAGE`, `COMPOSE_PROFILE` | override прод-профиля хука | `orchestrator`/`8500`/`orchestrator-orchestrator`/пусто |
| `SOURCE_IMAGE` (новый параметр хука) | образ для build-once retag | пусто → текущее поведение |
| `ORCH_DEPLOY_FINALIZE_DELAY_S` | задержка перед первым finalize-поллом | > 60с (health-loop хука) |
| `ORCH_DEPLOY_FINALIZE_MAX_ATTEMPTS` | бюджет finalize-defer | bounded (anti-livelock) |
Прописать дескрипторы в `.env.example` / INFRA.md. Реальные значения не коммитить.
## 4. Сетевые / процессные требования
- Detached host-процесс (ssh + setsid) обязан пережить рестарт прод-контейнера 8500.
- Finalizer-job исполняется в НОВОМ контейнере после рестарта (очередь restart-safe).
- MTTR авто-rollback < 60с (health-loop хука 10×6с уже укладывается, BR-8/AC-9).
## 5. Что НЕ требуется
- Новых контейнеров/портов/сервисов — нет.
- Изменений `docker-compose.yml` — не требуется (build-once через retag, не профиль).
- Multi-node / облако / message-queue — нет (принципы проекта).

View File

@@ -0,0 +1,34 @@
# Требования к данным / схеме БД — ORCH-036
Work Item: ORCH-036
Stage: architecture
Автор: architect
## Решение: миграция БД НЕ требуется
Схема SQLite (`events`, `tasks`, `agent_runs`, `jobs`) не меняется. Обоснование:
1. **Вердикт деплоя** — в `14-deploy-log.md` (frontmatter `deploy_status:`), как
сейчас. `_parse_deploy_status` не трогаем (AC-10).
2. **Approve / initiated / result-состояние** — restart-safe через **sentinel-файлы**
под `<repos_dir>/.deploy-state-<repo>/<work_item_id>/` (паттерн merge-lease
`<repos_dir>/.merge-lease-<repo>.json`), а не через новую таблицу/колонку:
- `approve-requested` — Фаза A;
- `initiated` — Фаза B (idempotency-guard);
- `result` — exit-code хука (пишет host-обёртка).
3. **Бюджет finalize-defer** считается из существующей таблицы `jobs`
(`task_content LIKE '%deploy-finalize%'`), как `_merge_defer_count` для merge-gate
— restart-safe, без новых полей.
4. **Finalizer-job** использует существующую структуру `jobs` (agent, repo,
task_content, task_id, available_at). Reserved-agent `deploy-finalizer` — это
значение в колонке `agent`, схема не меняется.
## Почему файлы, а не БД
- Sentinel должен быть виден И хосту (пишет `result`), И контейнеру (читает finalizer);
файл на общем mount это обеспечивает, SQLite-запись из host-обёртки — нет.
- Зеркалит уже принятый паттерн merge-lease (ORCH-43) — единообразие, restart-safe,
crash-реклейм по возрасту файла.
Если разработчик при реализации сочтёт необходимым поле статуса approve в БД —
это требует обновления данного ADR с обоснованием; по умолчанию — без миграции
(согласовано с TRZ §4).

View File

@@ -0,0 +1,23 @@
# Технические риски — ORCH-036
Work Item: ORCH-036
Stage: architecture
Автор: architect
| ID | Риск | Влияние | Вероятность | Митигация |
|----|------|---------|-------------|-----------|
| R-1 | Detached host-процесс не пережил рестарт 8500 (ssh-канал убит вместе с контейнером) | Деплой не завершён, `result` не записан, finalizer вечно defer'ит | Средняя | `setsid`/`nohup` + redirect отвязывает remote-процесс от ssh; интеграционная проверка на staging-цели (TC-08); finalize-defer bounded → по исчерпании `set_issue_blocked` + Telegram |
| R-2 | Преждевременное чтение `check_deploy_status` (вердикта ещё нет) | Ложный FAILED → ложный откат на development | Средняя | Фаза B возвращается БЕЗ advance; гейт запускает только finalizer (Фаза C) после появления `result`; defer пока `result` отсутствует |
| R-3 | Дрейф образ↔main: merge-gate сделал rebase, но staging-образ собран до rebase → build-once тегирует «не тот» код | В прод уезжает не точно то, что в `main` | Низкая | merge-gate (ORCH-43) делает re-test после rebase; build-once = «что валидировано на staging», что и есть контракт; задокументировано как осознанное ограничение; усиление (rebuild+revalidate staging после rebase) — отдельная задача |
| R-4 | Двойной Approved (человек кликнул дважды / дубль webhook) запускает деплой дважды | Двойной рестарт прода, гонка | Средняя | Маркер `initiated` (idempotency-guard); event-dedup webhook'ов Plane уже есть |
| R-5 | exit 2 хука (rollback тоже упал) → 8500 лежит → finalizer/новый контейнер не поднялся | Конвейер всех проектов встал | Низкая | health-loop + авто-rollback хука минимизируют; `restart: unless-stopped` поднимет контейнер на ПРЕДЫДУЩЕМ образе если retag не случился; exit 2 → `deploy_status: FAILED` + откат + Telegram-алерт; ручной `--rollback` хука как backstop |
| R-6 | Reserved-agent `deploy-finalizer` ошибочно уйдёт в LLM-путь лаунчера (`_spawn` → ValueError) | Finalizer не отработает | Низкая | Перехват ДО `_spawn` в `launch_job`; unit-тест маршрутизации |
| R-7 | sentinel-файлы не видны контейнеру/хосту (mount/uid) | Фазы B/C не синхронизируются | Низкая | Тот же mount и uid-модель, что у merge-lease (ORCH-40/43); HP-2 в 07-infra |
| R-8 | Approve через смену статуса Plane конфликтует с auto-advance других стадий | Случайный `Approved` на `deploy` ничего не ломает, но семантика неочевидна | Низкая | Перехват по `current_stage=="deploy"` + `finished_agent is None` + маркеры; задокументировать в deployer.md/INFRA, что `Approved` на `deploy` = «деплой в прод» |
| R-9 | Самодеплой ORCH ломает прод во время разработки самой ORCH-36 | Групповой простой (enduro-trails) | Низкая | Вся отладка — на staging-цели хука (8501); прод 8500 не трогать (AC: DoD); флаг approve=true |
## Сводный приоритет
- **Блокеры дизайна:** R-1, R-2 — закрыты архитектурой (setsid-detached + finalizer-defer).
- **Безопасность self-hosting:** R-5, R-9 — закрыты обязательным approve + staging-отладкой
+ авто-rollback + `restart: unless-stopped`.
- **Корректность:** R-3, R-4 — осознанные ограничения / idempotency-guard.

View File

@@ -0,0 +1,64 @@
---
type: review
work_item_id: ORCH-036
verdict: APPROVED
version: 2
---
# Review ORCH-036 — Исполняемый самодеплой стадии `deploy` (Вариант B)
## Summary
Re-review после фикса двух P1 из версии 1. Оба блокера устранены:
1. **Stale deploy-state маркеры** — добавлен `self_deploy.clear_state(repo, work_item_id)`
(never-raise, idempotent, рекурсивное удаление `<repos_dir>/.deploy-state-<repo>/<wi>/`)
в ветке БАГ-8-отката `check_deploy_status` FAILED (`_handle_qg_failure_rollbacks`,
`src/stage_engine.py`) и дополнительно в начале Фазы A (`_handle_self_deploy_phase_a`)
как belt-and-suspenders. Добавлен регрессионный тест
`tests/test_deploy_rollback.py::test_tc11_re_deploy_after_rollback_not_wedged`,
доказывающий, что после FAILED → откат → фикс → повторный заход на `deploy` Фаза B
РЕАЛЬНО инициирует деплой (нет no-op по устаревшему `initiated`), плюс
`tests/test_deploy_hook_mapping.py::test_clear_state_removes_all_markers_and_is_idempotent`.
2. **`.env.example`** — добавлен полный блок дескрипторов `ORCH_SELF_DEPLOY_*` /
`ORCH_DEPLOY_*` (14 настроек, плейсхолдеры, секреты не коммитятся) по образцу
merge-gate ORCH-043, с подробными комментариями.
Реализация трёхфазного исполняемого самодеплоя соответствует ADR-001 и закрывает
критерии приёмки AC-1…AC-13. Контракты `STAGE_TRANSITIONS` / `QG_CHECKS` /
`_parse_deploy_status` / БАГ-8 / terminal-sync / merge-gate (ORCH-43) НЕ тронуты;
условность по репо (`self_deploy_applies`) корректна; перехваты упорядочены верно
(Phase B после terminal-check, Phase A после merge-gate); `deploy-finalizer`
детерминированный no-LLM reserved-agent, перехвачен в launcher до `_spawn`. Все
импорты (`set_issue_in_review`, `plane_add_comment`, `set_issue_blocked`,
`send_telegram`) присутствуют. `pytest tests/`**568 passed**.
## Findings
### P0 — Blocker
- (нет)
### P1 — Must fix
- (нет — оба P1 из версии 1 устранены и покрыты тестами)
### P2 — Should fix
- (нет блокирующих; прежний P2 про сквозную процедуру оператора частично закрыт:
env-карта новых настроек добавлена в INFRA.md, пошаговый approve→deploy описан в
deployer.md и DEPLOY_HOOK.md)
## Документация
Обновлена содержательно и в том же PR:
- `.openclaw/agents/deployer.md` — стадия `deploy` переписана: self-hosting путь
(Фазы A/B/C, явный запрет рестарта 8500 изнутри агента) vs прежний синхронный
ssh-путь для не-self репо;
- `docs/operations/INFRA.md` — env-карта всех новых `ORCH_SELF_DEPLOY_*` / `ORCH_DEPLOY_*`;
- `docs/operations/DEPLOY_HOOK.md``SOURCE_IMAGE` build-once + прод-пример;
- `docs/architecture/README.md` — раздел «Исполняемый самодеплой стадии `deploy`»;
- `CHANGELOG.md` — запись Added (фича) + запись Fixed (review-fix: clear_state + .env.example);
- ADR `docs/work-items/ORCH-036/06-adr/ADR-001-executable-self-deploy.md` + глобальный
`docs/architecture/adr/adr-0007-executable-self-deploy.md`;
- **`.env.example`** — канонический шаблон (CLAUDE.md №8, ТЗ §2.6) дополнен (был пробел в v1).
Документация = golden source: изменения `src/` сопровождены синхронным обновлением
доки в том же PR. Ось документации — PASS.

View File

@@ -0,0 +1,90 @@
---
type: test-report
work_item_id: ORCH-036
result: PASS
---
# Test Report — ORCH-036
Исполняемый самодеплой стадии `deploy` (Вариант B) — дёргает хост-хук
`scripts/orchestrator-deploy-hook.sh`, три фазы (A/B/C), условность по self-hosting репо.
## Окружение
- Python: 3.12.13
- pytest: 8.3.3 (pluggy 1.6.0, anyio 4.13.0, asyncio 0.23.8 — mode AUTO)
- Worktree: `feature/ORCH-036-orch-36-deploy-b`
- Дата: 2026-06-06
- Prod (8500) во время тестов НЕ тронут: вся проверка изолированная (моки subprocess/ssh/хука).
Smoke выполнялся read-only GET-запросами.
## Smoke test API (prod 8500, read-only)
| Endpoint | Результат |
|----------|-----------|
| GET /health | `{"status":"ok","service":"orchestrator"}` — OK |
| GET /status | OK (отдаёт активные задачи) |
| GET /queue | OK (counts/max_concurrency/resilience; breaker=closed, preflight_ok=true) |
`curl` в окружении отсутствует — smoke выполнен через `urllib.request` (эквивалент GET).
## Результаты по тест-плану (04-test-plan.yaml)
| TC ID | Описание | Тест | Результат |
|-------|----------|------|-----------|
| TC-01 | exit 0 → deploy_status: SUCCESS | test_tc01_exit0_maps_to_success | PASS |
| TC-02 | exit 1 (rolled back) → FAILED | test_tc02_exit1_rolled_back_maps_to_failed | PASS |
| TC-03 | exit 2 (rollback тоже упал) → FAILED | test_tc03_exit2_rollback_also_failed_maps_to_failed | PASS |
| TC-04 | DEPLOY_REQUIRE_MANUAL_APPROVE дефолт == true | test_tc04_manual_approve_default_true | PASS |
| TC-05 | true и нет approve → прод-хук НЕ вызван | test_tc05_no_approve_does_not_call_prod_hook | PASS |
| TC-06 | true и approve → прод-хук вызван ровно 1 раз | test_tc06_approved_calls_prod_hook_exactly_once | PASS |
| TC-07 | is_self_hosting_repo: только orchestrator True | test_tc07_is_self_hosting_repo_only_orchestrator | PASS |
| TC-08 | self-репо: рестарт detached host-процессом | test_tc08_self_repo_launches_detached_host_process | PASS |
| TC-09 | не-self репо: прежний ssh-путь | test_tc09_non_self_repo_uses_legacy_path | PASS |
| TC-10 | FAILED → откат deploy→development, blocked, release lease | test_tc10_failed_deploy_rolls_back_to_development | PASS |
| TC-11 | staging_status FAILED → до deploy не доходит | test_tc11_staging_failed_never_reaches_deploy | PASS |
| TC-12 | успех → Plane-коммент + Telegram | test_tc12_success_notifies_plane_and_telegram | PASS |
| TC-13 | откат → Plane-коммент + Telegram | test_tc13_rollback_notifies_plane_and_telegram | PASS |
| TC-14 | build-once: retag staging-образа, без build | test_tc14_deploy_command_retags_staging_image_no_build | PASS |
| TC-15 | _parse_deploy_status контракт цел (проза не проходит) | test_qg_checks::test_tc15_* (5 кейсов) | PASS |
| TC-16 | STAGE_TRANSITIONS deploy/deploy-staging не изменены | test_stages::test_tc16_* | PASS |
| TC-17 | terminal-sync deploy→done сохранён | test_tc17_success_deploy_syncs_terminal_done | PASS |
| TC-18 | merge-gate (ORCH-43) на ребре не затронут | test_merge_gate (14 кейсов) | PASS |
| TC-19 | симуляция битого деплоя: авто-rollback → healthy, exit 1 | test_tc19_unhealthy_deploy_auto_rolls_back_exit1 | PASS |
Доп. регрессионные тесты (review-fix): `test_clear_state_removes_all_markers_and_is_idempotent`,
`test_tc11_re_deploy_after_rollback_not_wedged`оба PASS (stale deploy-state очищается, повторный
заход на deploy после отката не зависает).
## Покрытие критериев приёмки
| AC | Покрыт тестами | Статус |
|----|----------------|--------|
| AC-1 реальный деплой (не бумажный) | TC-01..03, TC-14, TC-19 | PASS |
| AC-2 self-репо рестарт detached, агент себя не убивает | TC-08 | PASS |
| AC-3 deploy_status из exit-code | TC-01..03 | PASS |
| AC-4 FAILED → откат на development | TC-10 | PASS |
| AC-5 ручной approve реально тормозит прод | TC-05, TC-06 | PASS |
| AC-6 уведомления о промоуте и откате | TC-12, TC-13 | PASS |
| AC-7 build-once (образ из staging) | TC-14 | PASS |
| AC-8 staging-гейт обязателен | TC-11 | PASS |
| AC-9 авто-rollback восстанавливает прод (MTTR<60с) | TC-19 | PASS |
| AC-10 инварианты не сломаны | TC-15..18 + полный регресс | PASS |
| AC-11 условность по репо (не-self не ломается) | TC-07, TC-09 | PASS |
| AC-12 флаг авто НЕ выключен (остаётся true) | TC-04 | PASS |
| AC-13 документация обновлена | проверено reviewer (12-review.md, APPROVED) | PASS |
## Вывод pytest
Полный регресс:
```
======================= 568 passed, 1 warning in 15.25s ========================
```
(единственный warning — PydanticDeprecatedSince20 в `src/config.py`, не связан с задачей)
Целевые модули тест-плана:
```
======================== 46 passed, 1 warning in 2.17s =========================
```
## Итог
**PASS** — все 19 TC зелёные, все критерии приёмки AC-1…AC-13 покрыты, полный регресс
568/568 passed, smoke API OK, прод (8500) не тронут. Задача готова к стадии deploy-staging.

View File

@@ -0,0 +1,39 @@
---
staging_status: SUCCESS
timestamp: 2026-06-06T21:47:48Z
base_url: http://localhost:8501
---
# Staging Gate Log
Staging test suite completed against the live `orchestrator-staging` instance (port 8501).
Executed canonically inside the container (ORCH-048, ADR-001):
```
docker exec orchestrator-staging \
python3 /repos/orchestrator/scripts/staging_check.py \
--base-url http://localhost:8501 --mode stub
```
(The agent container has no `docker` CLI; the canonical `docker exec` was invoked via the
Docker Engine API over the mounted `/var/run/docker.sock`, which is equivalent — the command
ran inside `orchestrator-staging` so the B6 registry-isolation check read the staging
process-env `.env.staging`.)
**Result: 10/10 checks PASS — exit code 0.**
| Block | Check | Verdict |
|-------|-------|---------|
| A SMOKE | A1 `GET /health` → 200 status=ok | PASS |
| A SMOKE | A2 `GET /queue` → 200 (counts/max_concurrency/resilience) | PASS |
| A SMOKE | A3 `ORCH_STAGING=true` (not prod) | PASS |
| B ACCESS | B4 Plane sandbox project accessible | PASS |
| B ACCESS | B5 Gitea `orchestrator-sandbox` accessible, push=true | PASS |
| B ACCESS | B6 Registry: sandbox present, prod ET/ORCH absent | PASS |
| C E2E | C7 Create issue in Plane SANDBOX | PASS |
| C E2E | C8 Trigger pipeline via `/webhook/plane` | PASS |
| C E2E | C9a Branch appears in `orchestrator-sandbox` | PASS |
| C E2E | C9b Analyst job enqueued in staging queue | PASS |
CLEANUP: test branch deleted, Plane SANDBOX issue deleted, staging DB job/task rows removed
(`try/finally` guaranteed). No prod (8500) container was touched.

View File

@@ -0,0 +1,7 @@
# Business Request: Self-deploy: retag берёт устаревший staging-образ (риск тихого регресса)
Work Item ID: ORCH-058
## Description
TBD

View File

@@ -0,0 +1,87 @@
# BRD — ORCH-058: Self-deploy retag берёт устаревший staging-образ (риск тихого регресса)
Work Item ID: ORCH-058
Тип: bug / техдолг инфраструктуры self-deploy
Источник: `docs/history/LESSONS_ORCH-036-selfdeploy.md` п.4 (самый опасный из 4 багов bootstrap ORCH-36)
## 1. Контекст
ORCH-36 сделал стадию `deploy` исполняемой для self-hosting репозитория `orchestrator`:
- Phase B (`src/self_deploy.py::build_deploy_command`) запускает детачед host-хук
`scripts/orchestrator-deploy-hook.sh` с параметром `SOURCE_IMAGE=orchestrator-orchestrator-staging`.
- Хук (шаг **2b**, BUILD-ONCE, ORCH-36 BR-6) делает `docker tag $SOURCE_IMAGE → $TARGET_IMAGE`
**без `docker build`** — «прод получает ровно тот артефакт, что прошёл staging».
Дизайн-предпосылка BUILD-ONCE: **staging-образ свеж и провалидирован**. На практике этой
гарантии НЕТ.
## 2. Проблема (корень)
Конвейер **нигде не пересобирает** образ `orchestrator-orchestrator-staging` из текущего
кода (HEAD `main` / провалидированной ветки):
- Стадия `deploy-staging` запускает только `scripts/staging_check.py` (e2e-проверка)
против **уже работающего** контейнера `orchestrator-staging` (8501) — что бы в нём ни
крутилось. Сборка staging-образа — ручная операция (STAGING.md / ORCH-34), вне конвейера.
- Между «образ собран» и «retag в прод» нет провенанс-связи с провалидированным коммитом.
Следствие (инцидент ORCH-36): staging-образ не пересобрали из нового `main`
`staging_check` прошёл против СТАРОГО кода → BUILD-ONCE retag промоутнул СТАРЫЙ образ в прод.
Деплой «зелёный» (`result=0`, health ok), но прод молча откатился на код 2-дневной давности:
пропал `deploy-finalizer` → задача не закрылась → бесконечная петля Phase B.
## 3. Почему это критично
> Это **самый опасный** из четырёх багов self-deploy: он **не падает**, а **тихо откатывает
> прод**. Зелёный гейт = ложный позитив. Орк обслуживает все проекты (enduro-trails) из одного
> прод-инстанса → тихий регресс инструмента = групповой инцидент для всех проектов.
Текущая защита (staging-гейт, merge-gate, health-check хука) НЕ ловит этот класс: все они
зелёные, потому что проверяют не тот артефакт, что уезжает в прод.
## 4. Бизнес-цель
Гарантировать инвариант: **в прод никогда не промоутится образ, не собранный из
провалидированного для данной задачи коммита; при невозможности это доказать — деплой
fail-fast (вердикт FAILED → откат на development), а не «тихо зелёный»**.
## 5. Объём (scope)
В объёме:
- Привязка артефакта (staging-образ → прод-retag) к провалидированному коммиту.
- Fail-fast при рассинхроне образа и кода (никаких тихих промоутов устаревшего).
- Условность как ORCH-35/36/43: реально только для `orchestrator`; прочие репо — no-op /
прежнее поведение.
- Контракт never-raise и fail-closed (на сомнении — не деплоить).
Вне объёма:
- Полный авто-approve прод-деплоя (ORCH-54).
- Изменение exit-code-контракта хука (0/1/2) и реестров `STAGE_TRANSITIONS` / `QG_CHECKS` как
набора стадий.
- Миграции схемы БД.
- Деплой/рестарт **прод**-контейнера `orchestrator` (8500) в рамках задачи.
## 6. Бизнес-требования (BR)
- **BR-1.** Образ, который BUILD-ONCE retag промоутит в прод, ДОЛЖЕН соответствовать коду,
провалидированному стадией `deploy-staging` для данной задачи (тот же git-коммит).
- **BR-2.** Если соответствие НЕ доказуемо (staging-образ собран не из провалидированного
коммита, либо провенанс невозможно прочесть) — деплой ОБЯЗАН fail-fast: вердикт `FAILED`,
штатный откат на `development` (контракт БАГ-8), без рестарта прода.
- **BR-3.** `staging_check.py` (e2e-валидация) ДОЛЖЕН прогоняться против артефакта,
соответствующего тому же провалидированному коммиту, что уедет в прод (нельзя валидировать
один образ, а катить другой).
- **BR-4.** Поведение условно: реально для `orchestrator`; для прочих репозиториев — no-op /
без регрессий прежнего синхронного деплоя.
- **BR-5.** Выбранное решение НЕ должно приводить к вечной блокировке деплоя (если механизм
свежести отсутствует — нужен путь, который доводит до зелёного, а не fail-fast'ит навсегда).
- **BR-6.** Контракт never-raise: сбой проверки свежести/провенанса не должен валить
stage_engine; на любом сомнении — fail-closed (трактуем как несоответствие).
- **BR-7.** Документация-голден-сорс: INFRA / DEPLOY_HOOK / STAGING / architecture README +
CHANGELOG обновляются в том же PR; решение оформляется ADR.
## 7. Связанные материалы
- `docs/history/LESSONS_ORCH-036-selfdeploy.md` (п.4 — корень)
- `docs/architecture/adr/adr-0007-executable-self-deploy.md`, `docs/work-items/ORCH-036/06-adr/ADR-001-executable-self-deploy.md`
- `src/self_deploy.py`, `scripts/orchestrator-deploy-hook.sh`, `src/config.py`
- `docs/operations/STAGING.md`, `docs/operations/DEPLOY_HOOK.md`, `docs/operations/INFRA.md`

View File

@@ -0,0 +1,126 @@
# ТЗ — ORCH-058: провенанс staging-образа перед BUILD-ONCE retag в прод
Work Item ID: ORCH-058
> Примечание: ТЗ фиксирует ТРЕБУЕМЫЕ изменения и точки в коде. **Выбор стратегии**
> (пересборка из HEAD `main` ПЕРЕД валидацией vs. fail-fast по провенансу образа, либо их
> комбинация) — решение **архитектора** (ADR в `06-adr/`). Ниже перечислены точки
> касания для обеих стратегий; архитектор выбирает и при необходимости сужает.
## 1. Инвариант, который нужно обеспечить
`INV-FRESH`: образ, передаваемый хуку как `SOURCE_IMAGE` для BUILD-ONCE retag в прод,
собран из ТОГО ЖЕ git-коммита, что прошёл `deploy-staging` для этой задачи. Если это
недоказуемо — деплой fail-fast (`deploy_status: FAILED` → откат на `development`, БАГ-8),
прод не трогается.
Якорь «провалидированного коммита» (architect фиксирует точно в ADR): SHA HEAD ветки задачи
после merge-gate rebase на `origin/main` (то, что валидировал `deploy-staging` + merge-gate).
## 2. Текущее поведение (что чинить)
| Место | Сейчас | Проблема |
|---|---|---|
| `scripts/orchestrator-deploy-hook.sh` шаг 2b | `docker tag $SOURCE_IMAGE → $TARGET_IMAGE` без проверки происхождения образа | промоутит любой образ под именем `orchestrator-orchestrator-staging`, даже устаревший |
| Стадия `deploy-staging` (`.openclaw/agents/deployer.md` + `staging_check.py`) | гоняет e2e против уже запущенного 8501, не пересобирая образ | валидирует не тот артефакт, что уедет в прод |
| `src/self_deploy.py::build_deploy_command` | передаёт `SOURCE_IMAGE`, `TARGET_*`, `COMPOSE_PROFILE`, `PREV_IMAGE_FILE`; провенанс/SHA не передаёт | хук не знает, какой коммит ожидать |
| `Dockerfile` | без OCI-лейбла `revision`/git-SHA | у образа нет машиночитаемого происхождения для проверки |
## 3. Задействованные модули `src/` и файлы
- `src/self_deploy.py` — основной (provenance-helpers + проброс ожидаемого SHA в команду хука).
- `src/config.py` — новые настройки (`ORCH_`-префикс обязателен, урок ORCH-36 п.2).
- `scripts/orchestrator-deploy-hook.sh` — fail-fast по провенансу и/или пересборка перед retag.
- `Dockerfile` — лейбл происхождения образа (для стратегии «провенанс по labels/sha»).
- `src/qg/checks.py` — опц. новый детерминированный под-чек свежести (если стратегия «гейт»).
- `src/stage_engine.py` — опц. точка вызова под-чека на ребре `deploy-staging → deploy`
(рядом с merge-gate, строки ~262288). **Реестр `STAGE_TRANSITIONS` не меняется.**
- `.openclaw/agents/deployer.md` — шаги стадии `deploy-staging` (если выбран rebuild-перед-валидацией).
- `docker-compose.yml` — опц. build-args/labels для staging-сервиса (если стратегия rebuild).
## 4. Требуемые изменения — стратегия A (пересборка из HEAD main перед валидацией)
A1. Перед прогоном `staging_check.py` стадия `deploy-staging` для `orchestrator` пересобирает
образ `orchestrator-orchestrator-staging` из провалидированного коммита (worktree ветки
после merge-gate rebase) и пересоздаёт контейнер 8501 на свежем образе.
A2. `staging_check.py` гоняется против свежего контейнера; на `SUCCESS` ровно ЭТОТ образ
становится `SOURCE_IMAGE` для прод-retag (loop closed).
A3. Детерминированно (без LLM в критическом пути): сборку/recreate выполняет код стадии или
host-хук в staging-режиме, не агент-деплойер «руками».
A4. Безопасность: операция трогает ТОЛЬКО staging (8501), НИКОГДА прод (8500).
## 5. Требуемые изменения — стратегия B (fail-fast по провенансу образа)
B1. `Dockerfile`: добавить лейбл происхождения, напр.
`LABEL org.opencontainers.image.revision=$GIT_SHA` через `ARG GIT_SHA` (build-arg).
B2. Сборка staging-образа (ручная или из стратегии A) проставляет `GIT_SHA` = коммит сборки.
B3. `src/self_deploy.py::build_deploy_command`: вычислить ожидаемый SHA провалидированного
коммита и пробросить в команду хука новым env (напр. `EXPECTED_REVISION=<sha>`).
Новый pure-helper, напр. `expected_revision(repo, branch) -> str` (never-raise).
B4. `scripts/orchestrator-deploy-hook.sh` шаг 2b: ПЕРЕД `docker tag` прочитать лейбл
`$SOURCE_IMAGE` (`docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}'`)
и сравнить с `$EXPECTED_REVISION`. Несовпадение / пустой лейбл / пустой ожидаемый SHA →
`log` + `exit 1` (fail-fast). Поведение обратносовместимо: при незаданном
`EXPECTED_REVISION` — текущее поведение (без проверки), чтобы не сломать не-self репо.
B5. exit 1 хука уже маппится `map_exit_code_to_status → FAILED` (контракт не меняется),
Phase C пишет `14-deploy-log.md` `deploy_status: FAILED` → откат на `development` (БАГ-8).
## 6. Требуемые изменения — опц. под-гейт (если архитектор выберет gate-side для B)
- Новый детерминированный (без LLM) под-чек, напр. `check_staging_image_fresh`, по образцу
`check_branch_mergeable` (ORCH-043): pure verdict-logic + условность (`self_deploy_applies`
/ `is_self_hosting_repo`), never-raise, для прочих репо → `(True, "N/A")`.
- Вызов на ребре `deploy-staging → deploy` ПЕРЕД Phase A (рядом с merge-gate, `stage_engine`
~268288). FAIL → откат на `development` (как merge-gate). Реестр стадий неизменен —
это под-гейт ребра, не новая стадия.
- Если выбран чисто хуковый fail-fast (раздел 5) — под-гейт не нужен.
## 7. Изменения API
Нет. Эндпоинты (`/health`, `/status`, `/queue`, `/webhook/*`) не меняются. Опц.: в снимок
`GET /queue` можно добавить диагностическое поле о свежести образа — НЕ обязательно.
## 8. Изменения схемы БД
Нет. Состояние deploy — sentinel-файлы (`.deploy-state-<repo>/<wi>/`, ORCH-36). Миграции
запрещены (как ORCH-36/43/53).
## 9. Конфигурация (`src/config.py`, ВСЕ с префиксом `ORCH_`)
Кандидаты (architect финализирует имена и дефолты):
- `image_freshness_enabled: bool = True` — kill-switch проверки (поэтапный раскат).
- `image_freshness_repos: str = ""` — CSV; пусто → только self-hosting (как `self_deploy_repos`).
- (для стратегии B) проброс `EXPECTED_REVISION` строится в `build_deploy_command`, отдельной
настройки может не требоваться.
- (для стратегии A) при необходимости — имя/тег staging-образа уже есть
(`deploy_prod_source_image`).
Урок ORCH-36 п.2: любая настройка, читаемая pydantic Settings, ОБЯЗАНА иметь префикс `ORCH_`.
## 10. Новые QG checks (если применимо)
- Опц. `check_staging_image_fresh` (см. §6) — добавить в реестр `QG_CHECKS` и в
snapshot-тест реестра (`tests/test_qg_registry_snapshot.py`). Только если выбран gate-side.
## 11. Артефакты pipeline (создать/обновить В ТОМ ЖЕ PR)
- `06-adr/ADR-001-<slug>.md` — выбор стратегии (A / B / A+B), якорь «провалидированного
коммита», точки fail-fast, условность, never-raise, отсутствие deadlock (BR-5).
- `docs/operations/DEPLOY_HOOK.md` — описание провенанс-проверки / пересборки и новых env.
- `docs/operations/STAGING.md` — как и когда пересобирается staging-образ в конвейере.
- `docs/operations/INFRA.md` — обновить топологию/риск self-deploy (закрыт п.4 каскада).
- `docs/architecture/README.md` — секция ORCH-36/58 (свежесть артефакта в BUILD-ONCE).
- `CHANGELOG.md` — запись ORCH-058.
- При выборе стратегии A: bootstrap-чеклист (урок ORCH-36 «сквозной»: реальный staging-прогон
до мержа).
## 12. Инварианты / ограничения (self-hosting safety)
- Никогда не рестартовать/ронять прод 8500 в рамках задачи (CLAUDE.md). Любая сборка/recreate —
только staging 8501.
- Никогда не пушить/форс-пушить `main` (как merge-gate).
- Контракты НЕ меняются: exit-code хука (0/1/2), `map_exit_code_to_status`,
`check_deploy_status`/`_parse_deploy_status`, БАГ-8 rollback, terminal-sync, merge-gate.
- Fail-closed: на любом сомнении (нет лейбла, нет ожидаемого SHA, ошибка inspect) —
трактовать как несоответствие → FAILED, никогда не промоутить «на авось».
- never-raise: helpers и под-чек не должны пробрасывать исключение в stage_engine.

View File

@@ -0,0 +1,71 @@
# Критерии приёмки — ORCH-058
Work Item ID: ORCH-058
Критерии сформулированы вокруг инварианта `INV-FRESH` и **не зависят** от выбранной
архитектором стратегии (A — пересборка, B — fail-fast по провенансу, A+B). Каждый — с
чётким условием PASS/FAIL.
## AC-1 — Соответствие артефакта коду (центральный инвариант)
- PASS: образ, который BUILD-ONCE retag промоутит в прод (`SOURCE_IMAGE`), доказуемо собран
из коммита, провалидированного стадией `deploy-staging` для этой задачи.
- FAIL: в прод может уехать образ, собранный не из провалидированного коммита.
## AC-2 — Fail-fast при рассинхроне (никаких тихих зелёных)
- PASS: если staging-образ собран НЕ из провалидированного коммита (или провенанс нечитаем),
деплой завершается `deploy_status: FAILED` и откатом на `development` (БАГ-8); прод НЕ
рестартуется на устаревший образ.
- FAIL: при рассинхроне деплой завершается `SUCCESS` / «зелёным», прод тихо откатывается.
## AC-3 — Fail-closed на сомнении
- PASS: при отсутствии лейбла происхождения, пустом ожидаемом SHA, ошибке `docker image
inspect` или любой неоднозначности — трактуется как несоответствие → FAILED (никогда не
промоутится «на авось»).
- FAIL: сомнительный/непроверяемый случай трактуется как «свежий» и промоутится.
## AC-4 — Валидация и промоут — один и тот же артефакт
- PASS: `staging_check.py` прогоняется против образа/контейнера, соответствующего тому же
провалидированному коммиту, который затем уезжает в прод.
- FAIL: валидируется один образ, а в прод retag'ается другой.
## AC-5 — Условность (self-hosting only)
- PASS: проверка/пересборка реальна только для `orchestrator` (и репо из `image_freshness_repos`,
если задан); для прочих репо — no-op, синхронный деплой не-self репо без регрессий.
- FAIL: логика срабатывает для не-self репозиториев или ломает их деплой.
## AC-6 — Никакого deadlock деплоя (BR-5)
- PASS: при штатном прогоне (staging-образ корректно отражает провалидированный коммит)
деплой доходит до `SUCCESS` и `deploy → done`; механизм свежести не блокирует валидный
деплой навсегда.
- FAIL: валидный деплой вечно fail-fast'ится / задача зависает на `deploy`.
## AC-7 — Контракты не изменены
- PASS: `STAGE_TRANSITIONS` (набор стадий), exit-code-контракт хука (0/1/2),
`map_exit_code_to_status`, `check_deploy_status`/`_parse_deploy_status`, БАГ-8 rollback,
terminal-sync, merge-gate — без изменений; схема БД без миграций.
- FAIL: затронут любой из перечисленных контрактов или добавлена миграция БД.
## AC-8 — never-raise
- PASS: сбой проверки свежести/провенанса (битый образ, ssh/docker error, отсутствующий
worktree) не пробрасывает исключение в `stage_engine`; возвращается безопасный вердикт.
- FAIL: исключение из новой логики всплывает и валит обработку стадии.
## AC-9 — Self-hosting safety
- PASS: новая логика НЕ рестартует/не роняет прод-контейнер `orchestrator` (8500) и не
пушит/форс-пушит `main`; любые сборки/recreate — только staging (8501).
- FAIL: нарушено любое из ограничений выше.
## AC-10 — Конфигурация и kill-switch
- PASS: новые настройки имеют префикс `ORCH_`; есть kill-switch (напр. `image_freshness_enabled`)
для поэтапного раската; при выключенном флаге — прежнее поведение.
- FAIL: настройка без `ORCH_`-префикса (не читается pydantic) или нет способа отключить.
## AC-11 — Документация (golden source)
- PASS: в том же PR обновлены DEPLOY_HOOK.md, STAGING.md, INFRA.md, architecture/README.md,
CHANGELOG.md и заведён ADR `06-adr/ADR-001-*`.
- FAIL: функционал изменён, документация/ADR не обновлены (→ reviewer REQUEST_CHANGES).
## AC-12 — Тесты зелёные
- PASS: `pytest tests/ -q` зелёный, включая новые тесты из `04-test-plan.yaml` и
snapshot-тест реестра QG (если добавлен под-чек).
- FAIL: любой тест из плана красный или регрессия существующих.

View File

@@ -0,0 +1,124 @@
work_item: ORCH-058
description: >
Провенанс staging-образа перед BUILD-ONCE retag в прод. Тесты покрывают инвариант
INV-FRESH: соответствие промоутируемого образа провалидированному коммиту, fail-fast
и fail-closed при рассинхроне, условность self-hosting, never-raise, неизменность
контрактов. Часть кейсов помечена strategy-зависимыми (A=пересборка, B=fail-fast по
провенансу) — финальный набор подтверждает архитектор в ADR; пишутся тесты для
выбранной стратегии.
tests:
- id: TC-01
type: unit
description: >
Pure provenance-verdict: SHA образа == ожидаемый SHA -> свежий (PASS).
Совпадающие revision дают вердикт "соответствует".
module: tests/test_image_freshness.py
expected: PASS
- id: TC-02
type: unit
description: >
Pure provenance-verdict: SHA образа != ожидаемый SHA -> НЕ свежий ->
вердикт несоответствия (вход для fail-fast).
module: tests/test_image_freshness.py
expected: PASS
- id: TC-03
type: unit
description: >
Fail-closed: пустой/отсутствующий лейбл образа ИЛИ пустой ожидаемый SHA ->
трактуется как несоответствие (никогда не "свежий по умолчанию").
module: tests/test_image_freshness.py
expected: PASS
- id: TC-04
type: unit
description: >
never-raise: provenance-helper при docker/ssh/inspect ошибке или отсутствующем
worktree возвращает безопасный вердикт (несоответствие), не пробрасывает исключение.
module: tests/test_image_freshness.py
expected: PASS
- id: TC-05
type: unit
description: >
Условность: для не-self репозитория проверка свежести = no-op (True/"N/A");
для orchestrator (или репо из image_freshness_repos) — реальна.
module: tests/test_image_freshness.py
expected: PASS
- id: TC-06
type: unit
description: >
[Стратегия B] build_deploy_command пробрасывает EXPECTED_REVISION=<sha>
в remote-команду хука рядом с SOURCE_IMAGE; формат env корректен (shlex-quote).
module: tests/test_deploy_build_once.py
expected: PASS
- id: TC-07
type: unit
description: >
[Стратегия B] Хук содержит ветку fail-fast: при заданном EXPECTED_REVISION и
несовпадении revision лейбла SOURCE_IMAGE -> exit 1 ПЕРЕД docker tag; при пустом
EXPECTED_REVISION -> обратносовместимое поведение (без проверки). Статическая
проверка текста scripts/orchestrator-deploy-hook.sh (паттерн test_deploy_build_once).
module: tests/test_deploy_hook_provenance.py
expected: PASS
- id: TC-08
type: unit
description: >
[Стратегия B] Dockerfile объявляет ARG GIT_SHA и LABEL
org.opencontainers.image.revision=$GIT_SHA (статическая проверка текста Dockerfile).
module: tests/test_deploy_hook_provenance.py
expected: PASS
- id: TC-09
type: unit
description: >
Маппинг контракта: exit 1 хука (fail-fast по провенансу) ->
map_exit_code_to_status == "FAILED" (контракт ORCH-36 не изменён).
module: tests/test_deploy_hook_mapping.py
expected: PASS
- id: TC-10
type: integration
description: >
Stale-образ -> fail-fast end-to-end: на ребре deploy-staging->deploy при
несоответствии образа Phase B/хук дают FAILED -> advance_stage откатывает на
development (БАГ-8), прод не "зелёный". Прод-рестарт замокан.
module: tests/test_stage_engine.py
expected: PASS
- id: TC-11
type: integration
description: >
Свежий образ -> happy path: соответствие revision -> деплой доходит до SUCCESS и
deploy->done; механизм свежести не блокирует валидный деплой (anti-deadlock, AC-6).
Host-процесс/хук замокан.
module: tests/test_stage_engine.py
expected: PASS
- id: TC-12
type: unit
description: >
[Если выбран gate-side] check_staging_image_fresh зарегистрирован в QG_CHECKS;
snapshot-тест реестра обновлён и зелёный.
module: tests/test_qg_registry_snapshot.py
expected: PASS
- id: TC-13
type: unit
description: >
Конфигурация: новые настройки (image_freshness_enabled / image_freshness_repos)
читаются с префиксом ORCH_ и имеют дефолты; kill-switch off -> прежнее поведение.
module: tests/test_config.py
expected: PASS
- id: TC-14
type: unit
description: >
Регрессия контрактов: STAGE_TRANSITIONS (набор стадий) и exit-code-контракт хука
(0/1/2) не изменены существующими правками.
module: tests/test_stages.py
expected: PASS

View File

@@ -0,0 +1,209 @@
# ADR-001 (ORCH-058): Провенанс staging-образа перед BUILD-ONCE retag в прод
## Статус
Accepted (design) — реализация в ветке `feature/ORCH-058-self-deploy-retag-staging`.
Метка: `arch:major-change` (новая deploy-safety модель + новый QG + новый режим хука).
## Контекст
ORCH-36 сделал стадию `deploy` исполняемой для self-hosting (`orchestrator`): Phase B
(`self_deploy.build_deploy_command`) запускает детачед host-хук, который шагом **2b**
(BUILD-ONCE) делает `docker tag $SOURCE_IMAGE → $TARGET_IMAGE` **без `docker build`**
«прод получает ровно тот артефакт, что прошёл staging».
Дизайн-предпосылка BUILD-ONCE: **staging-образ свеж и собран из провалидированного кода**.
На практике этой гарантии НЕТ (BRD §2):
- Стадия `deploy-staging` запускает только `scripts/staging_check.py` против **уже
работающего** контейнера 8501 — что бы в нём ни крутилось. Пересборка staging-образа —
ручная операция (STAGING.md / ORCH-34), вне конвейера.
- Между «образ собран» и «retag в прод» нет провенанс-связи с провалидированным коммитом.
Инцидент (LESSONS_ORCH-036 п.4 — **самый опасный** из 4 багов bootstrap): staging-образ
не пересобрали из нового `main``staging_check` прошёл против СТАРОГО кода → BUILD-ONCE
retag промоутнул СТАРЫЙ образ в прод. Деплой «зелёный» (`result=0`, health ok), но прод
**молча откатился** на код 2-дневной давности. Орк обслуживает все проекты из одного
прод-инстанса → тихий регресс инструмента = групповой инцидент.
Текущая защита (staging-гейт, merge-gate, health-check хука) этот класс НЕ ловит: все
гейты зелёные, потому что проверяют **не тот артефакт**, что уезжает в прод.
## Инвариант, который нужно обеспечить
`INV-FRESH` (ТЗ §1): образ, передаваемый хуку как `SOURCE_IMAGE` для BUILD-ONCE retag в
прод, собран из ТОГО ЖЕ git-коммита, что прошёл `deploy-staging` для этой задачи. Если
это недоказуемо — деплой fail-fast (`deploy_status: FAILED` → откат на `development`,
БАГ-8), прод не трогается.
### Якорь «провалидированного коммита»
**SHA = `git rev-parse HEAD` в worktree ветки задачи ПОСЛЕ merge-gate** (т.е. после
возможного `auto_rebase_onto_main` + `push --force-with-lease`). Это ровно тот tree,
который merge-gate ре-тестировал зелёным и который сольётся в `main`. Один helper
`validated_revision(repo, branch)` (never-raise) вычисляет SHA и служит ЕДИНСТВЕННЫМ
источником и для штампа сборки (Стратегия A), и для ожидаемого ревижна (Стратегия B) —
два потребителя одного якоря не могут разойтись.
## Решение: A + B (defense in depth)
Ни одна стратегия по отдельности не закрывает задачу:
- **B в одиночку** (fail-fast по провенансу) делает тихий промоут структурно невозможным,
НО если staging-образ устарел — fail-fast'ит **навсегда** (нет пути к зелёному без
ручной пересборки) → нарушает BR-5 / AC-6 (deadlock), воспроизводит ровно тот
bootstrap-разрыв, который мы устраняем.
- **A в одиночку** (пересборка из провалидированного коммита) закрывает петлю «валидируем =
промоутим», НО не имеет утверждения В МОМЕНТ retag: гонка/отключение/сбой пересборки
снова даст тихий промоут.
Поэтому берём **обе**, как взаимодополняющие слои:
### Стратегия A — пересборка staging-образа из провалидированного коммита (liveness, AC-4/AC-6)
Для self-hosting на ребре `deploy-staging → deploy`, **после merge-gate** (когда
валидированный HEAD финализирован) и **до Phase A**, детерминированный код:
1. Вычисляет `sha = validated_revision(repo, branch)`.
2. Пересобирает `orchestrator-orchestrator-staging` из **worktree ветки** (build-context =
валидированный tree) с `--build-arg GIT_SHA=<sha>` и пересоздаёт контейнер 8501 на
свежем образе (`--no-build`).
3. Прогоняет `staging_check.py --mode stub` против свежего 8501.
Результат: ровно ЭТОТ образ (с лейблом `revision=<sha>`) становится `SOURCE_IMAGE` для
прод-retag → петля замкнута, валидируем и промоутим один артефакт (AC-4). Пересборка/
recreate трогают **ТОЛЬКО staging (8501)**, НИКОГДА прод (8500) (AC-9).
Исполнение — через host (ssh, синхронно): docker CLI / compose доступны на ХОСТЕ, не в
контейнере (Dockerfile ставит только `openssh-client git`; staging_check уже гоняется
`docker exec`-ом на хосте). Новый режим хука `--build-staging` (см. ниже) выполняет сборку
и recreate. Синхронный ssh достаточен — рестарт staging не убивает прод-worker (в отличие
от Phase B, где нужен detached + finalizer).
Реализуется как **детерминированный QG-под-чек `check_staging_image_fresh`** (по образцу
`check_branch_mergeable`, ORCH-043): pure-условность + never-raise; для прочих репо →
`(True, "N/A")`. Регистрируется в `QG_CHECKS` и в `tests/test_qg_registry_snapshot.py`.
Вызов — на ребре через `_handle_image_freshness(...)` в `stage_engine` (рядом с
`_handle_merge_gate`, ПОСЛЕ него, ДО Phase A). FAIL → откат на `development` + release
merge-lease (как merge-gate). **`STAGE_TRANSITIONS` (набор стадий) НЕ меняется** — это
под-гейт ребра.
### Стратегия B — fail-closed провенанс-guard в хуке (safety, AC-1/AC-2/AC-3)
1. **`Dockerfile`**: `ARG GIT_SHA` + `LABEL org.opencontainers.image.revision=$GIT_SHA`.
Без build-arg лейбл пустой → fail-closed на стороне B (см. ниже).
2. **`build_deploy_command`**: вычисляет `EXPECTED_REVISION = validated_revision(repo,
branch)` и пробрасывает в env команды хука.
3. **`orchestrator-deploy-hook.sh` шаг 2b** — ПЕРЕД `docker tag`:
- читает лейбл `SOURCE_IMAGE`:
`docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}' "$SOURCE_IMAGE"`;
- сравнивает с `$EXPECTED_REVISION`;
- несовпадение / пустой лейбл / пустой `EXPECTED_REVISION` / ошибка inspect →
`log` + `exit 1` (**fail-closed**, никогда не промоутить «на авось»).
- **Обратная совместимость:** при НЕзаданном `EXPECTED_REVISION` — текущее поведение
(проверка пропускается), чтобы не сломать не-self репо и legacy-вызовы.
4. `exit 1` уже маппится `map_exit_code_to_status → FAILED` (контракт не меняется), Phase C
пишет `deploy_status: FAILED` → откат на `development` (БАГ-8). Прод не рестартуется на
устаревший образ — guard срабатывает ДО `docker tag`/restart.
### Новый режим хука `--build-staging` (для Стратегии A)
`orchestrator-deploy-hook.sh --build-staging` (env: `GIT_SHA`, `BUILD_CONTEXT` = host-путь
worktree, `TARGET_IMAGE=orchestrator-orchestrator-staging`, `TARGET_SERVICE`,
`COMPOSE_PROFILE=staging`, `TARGET_PORT=8501`):
`docker build --build-arg GIT_SHA=<sha> -t <TARGET_IMAGE> <BUILD_CONTEXT>` →
`docker compose --profile staging up -d --no-build orchestrator-staging` → health 8501.
Тот же exit-code-контракт (0=ok). Дефолты режима — STAGING-safe (как у `--deploy`).
Host-путь build-context выводится из container-пути worktree заменой
`repos_dir → host_repos_dir` (как `host_state_dir` в `self_deploy.py`); требуется
производный helper host-worktree-пути (или новая настройка `ORCH_HOST_WORKTREES_DIR`).
## Конфигурация (`src/config.py`, все с префиксом `ORCH_` — урок ORCH-36 п.2)
- `image_freshness_enabled: bool = True` — **единый** kill-switch ВСЕЙ фичи (A и B вместе).
`False` → ни пересборки, ни проброса `EXPECTED_REVISION` → поведение ровно как ORCH-36
(BUILD-ONCE без guard). A и B включаются/выключаются **как одно целое**, чтобы не было
опасной полу-конфигурации «B без A» (вечный fail-fast).
- `image_freshness_repos: str = ""` — CSV; пусто → только self-hosting (как
`self_deploy_repos` / `merge_gate_repos`).
> **Инвариант конфигурации (AC-6):** B активен ТОЛЬКО когда активен A. По умолчанию
> (`image_freshness_enabled=True`) валидный деплой всегда доходит до зелёного (A пересобирает
> → лейбл == EXPECTED → B пропускает). Полное выключение → legacy ORCH-36 поведение.
## Порядок на ребре `deploy-staging → deploy` (self-hosting)
1. `check_staging_status` (существующий) — первичный staging-вердикт агента (smoke,
что staging-инфра жива).
2. merge-gate `check_branch_mergeable` (существующий) — финализирует валидированный HEAD
(rebase если позади, ре-тест зелёный, lease HELD). DEFER на busy-lock → возврат без
пересборки.
3. **`check_staging_image_fresh` (НОВЫЙ, Стратегия A)** — пересборка из валидированного
HEAD + recreate 8501 + `staging_check`. FAIL → откат на `development` + release lease.
4. Phase A (существующий) → запрос approve.
5. Phase B (human Approved) → `build_deploy_command` с `EXPECTED_REVISION` → хук-guard (B)
→ BUILD-ONCE retag только при совпадении → restart прод → Phase C finalizer.
> Двойной прогон `staging_check` (агент на стадии + код на шаге 3) — **намеренный**: первый
> валидирует УЖЕ работающий (потенциально устаревший) 8501 как soft pre-check; авторитетный
> — шаг 3 против СВЕЖЕГО образа, который и уедет в прод. `--mode stub` быстр и без LLM-трат.
## Контракты, которые НЕ меняются (AC-7)
`STAGE_TRANSITIONS` (набор стадий), exit-code-контракт хука (0/1/2),
`map_exit_code_to_status`, `check_deploy_status` / `_parse_deploy_status` (frontmatter-only),
БАГ-8 rollback, terminal-sync `deploy → done`, merge-gate (ORCH-43), Phase A/B/C ORCH-36.
**Схема БД — без миграций** (состояние свежести не персистится в БД; провенанс живёт в
лейбле образа). Добавление `check_staging_image_fresh` в `QG_CHECKS` — ожидаемое расширение
реестра (ТЗ §10), не входит в замороженный список AC-7.
## Last-line-of-defence / fail-closed (AC-2/AC-3)
Даже если A отключена/проиграла гонку/сбойнула — **B (хук-guard) делает тихий промоут
устаревшего образа структурно невозможным**: рассинхрон лейбла и `EXPECTED_REVISION` →
`exit 1` ДО retag → FAILED → откат. На любом сомнении (нет лейбла, пустой ожидаемый SHA,
ошибка inspect) — трактуется как несоответствие. Прод никогда не трогается «на авось».
## never-raise (AC-8)
`validated_revision`, `rebuild_staging_image`, `check_staging_image_fresh`,
`build_deploy_command` (проброс EXPECTED) — все защищены try/except, любая ошибка → безопасный
вердикт (для A-под-чека: `(False, reason)` с release lease; пустой `EXPECTED_REVISION` на
сомнении → B fail-closed). Исключение никогда не всплывает в `stage_engine`.
## Последствия
**Плюсы**
- Класс «тихого регресса прод» закрыт структурно (B), а валидный деплой всегда доходит до
зелёного (A) — bootstrap-разрыв «ручная пересборка staging» устранён.
- Валидируем и промоутим один и тот же артефакт (AC-4); провенанс машиночитаем (лейбл).
- Единый kill-switch, поэтапный раскат, условность только для self-hosting — без регрессий
для не-self репо.
**Минусы / ограничения**
- Латентность ребра растёт: +`docker build` staging + recreate 8501 + повторный
`staging_check` перед Phase A. Приемлемо (выполняется в monitor-треде, как merge-gate
re-test; bounded timeouts).
- `staging_check` гоняется дважды (soft pre-check агента + авторитетный код) — осознанная
плата за AC-4. Возможная будущая оптимизация: облегчить шаг 3 до health+revision-smoke,
если merge-gate re-test признать достаточным для кода.
- Требуется host-доступ к `docker build`/`compose` под slin (как для `--deploy`) и writable
build-context (worktree) — заложено инфра-требованиями (07).
- Новая под-компонента (QG `check_staging_image_fresh` + режим хука `--build-staging`) →
`arch:major-change`.
## Альтернативы (отклонены)
- **Только B.** Deadlock без авто-пересборки (BR-5/AC-6). ❌
- **Только A.** Нет утверждения в момент retag → гонка/отключение снова даёт тихий промоут
(AC-2/AC-3). ❌
- **Rebuild в хуке на Phase B (прод-сторона).** Уничтожает BUILD-ONCE (прод-rebuild) и
промоутит образ, который staging-e2e никогда не валидировал. ❌
- **Rebuild напрямую из контейнера через docker.sock.** В образе нет docker CLI/compose;
staging-операции и так host-side (ssh). ❌
## Связанные ADR
Глобальный: `docs/architecture/adr/adr-0008-staging-image-provenance.md`.
`adr-0007-executable-self-deploy` (ORCH-36, BUILD-ONCE), `adr-0006-merge-gate` (ORCH-43,
образец edge-под-гейта), `adr-0003-staging-gate` (ORCH-35, условность), `adr-0005`
(run-as-host-uid).

View File

@@ -0,0 +1,71 @@
# Инфра-требования — ORCH-058
Work Item ID: ORCH-058
Топология не меняется (тот же сервер mva154, те же контейнеры 8500/8501, общая БД). Меняется
**что делает self-deploy на ребре `deploy-staging → deploy`** для self-hosting. Полная
топология/риски — `docs/operations/INFRA.md` (обновить в том же PR).
## IR-1. Host-сборка staging-образа (Стратегия A)
Шаг свежести пересобирает `orchestrator-orchestrator-staging` на ХОСТЕ (docker CLI/compose
есть на хосте, НЕ в контейнере — образ ставит только `openssh-client git`). Требуется:
- Рабочий ssh `slin@127.0.0.1` (уже есть, ORCH-36 / LESSONS п.12: passwd-запись uid 1000,
ключ смонтирован, `ORCH_DEPLOY_*` префиксы).
- На хосте под `slin` доступны `docker build` и `docker compose --profile staging`
(recreate 8501). Группа docker (`group_add: "999"` / host-доступ к `docker.sock`) — уже
настроено.
- **Build-context = host-путь worktree** валидированной ветки
(`/home/slin/repos/_wt/<repo>/<branch-slug>`), читаемый под `slin`. Worktree уже
создаётся launcher'ом/merge-gate под slin (ADR-0005 run-as-host-uid) — права ок.
- Лог-директория хука writable под slin (`/var/log/orchestrator`, LESSONS п.3) — уже.
## IR-2. Вывод host-пути worktree
В контейнере worktree виден как `ORCH_WORKTREES_DIR=/repos/_wt/...`; на хосте — как
`/home/slin/repos/_wt/...`. Маппинг = замена `repos_dir → host_repos_dir` (как
`self_deploy.host_state_dir`). Реализация: производный helper host-worktree-пути, либо новая
настройка `ORCH_HOST_WORKTREES_DIR` (дефолт `/home/slin/repos/_wt`). Без неё — деривация из
`host_repos_dir`.
## IR-3. OCI-лейбл происхождения (Стратегия B)
`Dockerfile`: `ARG GIT_SHA` + `LABEL org.opencontainers.image.revision=$GIT_SHA`. Сборки БЕЗ
build-arg (ручные/legacy) дают пустой лейбл → B fail-closed (это by design, не регрессия:
прод-retag без доказуемого провенанса должен падать). Любой существующий способ сборки прод/
staging-образа (CI, ручной) при включённой фиче ОБЯЗАН передавать `--build-arg GIT_SHA=<sha>`,
иначе деплой задачи fail-fast'нется на guard. Шаг A это делает автоматически.
## IR-4. ssh-режим хука `--build-staging`
Новый режим `orchestrator-deploy-hook.sh --build-staging` запускается синхронно (рестарт
staging безопасен, detached/finalizer не нужны — в отличие от Phase B прод). Дефолты режима —
STAGING-safe (`TARGET_PORT=8501`, `--profile staging`). Прод (8500) этим режимом НЕ
затрагивается.
## IR-5. Конфигурация (env, префикс `ORCH_`)
- `ORCH_IMAGE_FRESHNESS_ENABLED` (дефолт true) — единый kill-switch A+B.
- `ORCH_IMAGE_FRESHNESS_REPOS` (дефолт пусто → self-hosting).
- (опц.) `ORCH_HOST_WORKTREES_DIR` (дефолт `/home/slin/repos/_wt`).
`EXPECTED_REVISION` для хука строится в `build_deploy_command` — отдельной настройки не
требует. `deploy_prod_source_image` (= `orchestrator-orchestrator-staging`) переиспользуется.
## IR-6. Безопасность self-hosting (инварианты)
- Любые `docker build` / `compose up` / recreate — ТОЛЬКО staging (8501); прод (8500) не
рестартуется в рамках шага свежести.
- `main` не пушится; force-only — `--force-with-lease` на ветку задачи (merge-gate, без
изменений). Шаг A не пушит ничего (только локальный `docker build`).
- B-guard срабатывает ДО `docker tag`/restart — прод не трогается на сомнении.
## IR-7. Bootstrap-чеклист (урок ORCH-36 «сквозной»)
Перед мержем ORCH-058 — **реальный** прогон в staging-петле (не только бумажные гейты):
сборка staging из worktree с GIT_SHA → лейбл присутствует
(`docker image inspect ... revision`) → recreate 8501 → `staging_check` зелёный →
`build_deploy_command` отдаёт непустой `EXPECTED_REVISION` → хук-guard пропускает при
совпадении и `exit 1` при подмене `SOURCE_IMAGE` на устаревший. Зафиксировать в bootstrap-
заметке (как LESSONS_ORCH-036).

View File

@@ -0,0 +1,16 @@
# Технические риски — ORCH-058
Work Item ID: ORCH-058
| ID | Риск | Вероятность / Влияние | Митигация |
|----|------|----------------------|-----------|
| R-1 | **Полу-конфигурация «B без A»** → вечный fail-fast деплоя (B падает, никто не пересобирает) | Низк. / Высок. (deadlock, BR-5) | Единый kill-switch `image_freshness_enabled` включает/выключает A и B **как целое**; раздельных флагов A/B нет. Дефолт — оба включены. AC-6. |
| R-2 | **Рассинхрон якоря**: merge-gate делает rebase ПОСЛЕ того, как агент прогнал staging_check → HEAD изменился | Сред. / Сред. | Якорь берётся ПОСЛЕ merge-gate; шаг A пересобирает из post-rebase HEAD; авторитетный staging_check — против свежего образа. Pre-check агента — soft. |
| R-3 | **Гонка**: между пересборкой A и Phase B human-approve worktree HEAD сместился | Низк. / Высок. | B сверяет лейбл образа с `EXPECTED_REVISION`=validated_revision на момент Phase B; рассинхрон → fail-closed `exit 1`, прод не трогается. AC-2/AC-3. |
| R-4 | **Пустой лейбл** (ручная/legacy/CI-сборка без `--build-arg GIT_SHA`) | Сред. / Высок. | Fail-closed: пустой лейбл → несоответствие → `exit 1`. By design. Шаг A всегда передаёт GIT_SHA. IR-3 фиксирует требование к любым сборкам. |
| R-5 | **Латентность ребра**: +docker build staging +recreate +повторный staging_check перед approve | Высок. / Низк. | Bounded timeouts; выполняется в monitor-треде (как merge-gate re-test). `staging_check --mode stub` без LLM-трат. Приемлемо. |
| R-6 | **Сборка/recreate случайно затронет прод (8500)** | Низк. / Критич. | Режим хука `--build-staging` со STAGING-safe дефолтами (8501, `--profile staging`); код шага A никогда не передаёт прод-параметры. AC-9. Тест-инвариант: цель != прод. |
| R-7 | **docker build на хосте падает** (нет места, недоступен daemon, битый worktree) | Низк. / Сред. | never-raise: `check_staging_image_fresh``(False, reason)` + release lease → откат на `development` (не зависание, не тихий промоут). AC-8. |
| R-8 | **Двойной staging_check** воспринят как баг/лишняя трата | Сред. / Низк. | Документировано как намеренное (soft pre-check агента vs авторитетный код против промоутимого образа). Будущая оптимизация — облегчить шаг A. |
| R-9 | **Самохостинг-bootstrap**: фича не действует, пока сама не в проде (старый прод-образ без лейбла) | Высок. (однократно) / Сред. | Bootstrap-чеклист (IR-7): первый реальный staging-прогон + ручной разрыв; B обратносовместим (без `EXPECTED_REVISION` — старое поведение), раскат поэтапный через флаг. |
| R-10 | **Деградация не-self репо** | Низк. / Высок. | Условность (`image_freshness_repos` пусто → только orchestrator); для прочих — `(True, "N/A")` + хук без `EXPECTED_REVISION` = прежний путь. AC-5. |

View File

@@ -9,11 +9,29 @@
# TARGET_IMAGE - image name for retag (default: orchestrator-orchestrator-staging)
# COMPOSE_PROFILE - docker compose profile (default: staging)
# PREV_IMAGE_FILE - path to prev-image snapshot (default: $REPO/.deploy-prev-image-staging)
# SOURCE_IMAGE - build-once source image (default: unset; ORCH-36)
# When set, the prevalidated (staging) image is retagged onto
# TARGET_IMAGE instead of rebuilding — guarantees prod runs the
# exact artefact that passed staging (no `docker build`).
# EXPECTED_REVISION- expected git SHA of SOURCE_IMAGE (default: unset; ORCH-58)
# Strategy B fail-closed provenance guard: when set, the
# SOURCE_IMAGE's org.opencontainers.image.revision label MUST
# equal this value before the BUILD-ONCE retag, else exit 1
# (a stale image is never promoted). Unset -> no check (legacy).
# GIT_SHA - build-arg for --build-staging (default: unset; ORCH-58)
# BUILD_CONTEXT - docker build context dir (default: $REPO; --build-staging)
# STAGING_CONTAINER- container to docker-exec staging_check in (--build-staging;
# default: $TARGET_SERVICE → orchestrator-staging; ORCH-58)
# STAGING_CHECK_PATH- staging_check.py path inside that container (--build-staging;
# default: /repos/orchestrator/scripts/staging_check.py; ORCH-58)
# STAGING_CHECK_MODE- staging_check mode stub|full-real (--build-staging;
# default: stub — fast, no LLM spend; ORCH-58)
# LOG - log file path (default: /var/log/orchestrator/deploy-hook.log)
#
# Usage:
# ./orchestrator-deploy-hook.sh [--deploy] # normal deploy (default)
# ./orchestrator-deploy-hook.sh --rollback # manual rollback
# ./orchestrator-deploy-hook.sh [--deploy] # normal deploy (default)
# ./orchestrator-deploy-hook.sh --rollback # manual rollback
# ./orchestrator-deploy-hook.sh --build-staging # ORCH-58: rebuild staging image (8501)
set -euo pipefail
@@ -25,6 +43,14 @@ TARGET_PORT="${TARGET_PORT:-8501}"
TARGET_IMAGE="${TARGET_IMAGE:-orchestrator-orchestrator-staging}"
COMPOSE_PROFILE="${COMPOSE_PROFILE:-staging}"
PREV_IMAGE_FILE="${PREV_IMAGE_FILE:-$REPO/.deploy-prev-image-staging}"
# Build-once (ORCH-36): optional prevalidated source image to retag onto
# TARGET_IMAGE. Unset -> backward-compatible (no retag), exit-code contract intact.
SOURCE_IMAGE="${SOURCE_IMAGE:-}"
# Provenance guard (ORCH-58, Strategy B): expected git SHA of SOURCE_IMAGE. Unset
# -> backward-compatible (no provenance check), exit-code contract intact.
EXPECTED_REVISION="${EXPECTED_REVISION:-}"
# The OCI-standard label key the Dockerfile stamps with the build commit.
REVISION_LABEL="org.opencontainers.image.revision"
# ---- Log setup -------------------------------------------------------------
LOG_DIR=/var/log/orchestrator
@@ -122,6 +148,57 @@ if [[ "${1:-}" == "--rollback" ]]; then
fi
fi
# ============================================================================
# --build-staging mode (ORCH-58, Strategy A): rebuild the STAGING image from the
# VALIDATED commit, recreate 8501, and run the AUTHORITATIVE staging_check against
# the fresh image, so the artefact we validate is the exact one later BUILD-ONCE
# retagged to prod (INV-FRESH, AC-4). Builds/recreates STAGING ONLY (8501) — never
# prod (8500). Same exit-code contract (0 = healthy + staging_check PASS).
# GIT_SHA - commit stamped into the image revision label (build-arg).
# BUILD_CONTEXT - docker build context (host worktree of the validated commit).
# Steps: (1) docker build → (2) recreate 8501 → (3a) health-check →
# (3b) staging_check.py --mode stub against the fresh 8501 (ADR-001 step 3).
# ============================================================================
if [[ "${1:-}" == "--build-staging" ]]; then
BUILD_CONTEXT="${BUILD_CONTEXT:-$REPO}"
GIT_SHA="${GIT_SHA:-}"
log "BUILD-STAGING: rebuilding $TARGET_IMAGE from $BUILD_CONTEXT (GIT_SHA=$GIT_SHA, port=$TARGET_PORT)"
if ! docker build --build-arg GIT_SHA="$GIT_SHA" -t "$TARGET_IMAGE" "$BUILD_CONTEXT" >> "$LOG" 2>&1; then
log "BUILD-STAGING: docker build failed - aborting (exit 1)"
exit 1
fi
log "BUILD-STAGING: recreating $TARGET_SERVICE (profile=$COMPOSE_PROFILE) on the fresh image"
if [[ -n "$COMPOSE_PROFILE" ]]; then
docker compose --profile "$COMPOSE_PROFILE" up -d --no-build "$TARGET_SERVICE" >> "$LOG" 2>&1
else
docker compose up -d --no-build "$TARGET_SERVICE" >> "$LOG" 2>&1
fi
log "BUILD-STAGING: running health-check on port $TARGET_PORT (10x6s)"
if ! health_check 10 6 "build-staging-health"; then
log "BUILD-STAGING: health FAILED after rebuild (exit 1)"
exit 1
fi
log "BUILD-STAGING: $TARGET_SERVICE healthy on fresh image"
# (3b) ORCH-58 (Strategy A, step 3 — ADR-001): authoritative e2e validation of
# the FRESH image. Run staging_check.py against the just-rebuilt 8501 INSIDE the
# staging container (ORCH-048 canonical: it reads its OWN staging registry env, so
# B6 is correct; the script lives at /repos/... via bind-mount, not in /app). This
# is the same artefact later BUILD-ONCE retagged to prod, so we validate exactly
# what we promote (AC-4). Any non-zero (FAIL or ORCH_STAGING safety-abort) -> exit 1
# -> freshness gate FAIL -> rollback to development. Same exit-code contract.
STAGING_CONTAINER="${STAGING_CONTAINER:-$TARGET_SERVICE}"
STAGING_CHECK_PATH="${STAGING_CHECK_PATH:-/repos/orchestrator/scripts/staging_check.py}"
STAGING_CHECK_MODE="${STAGING_CHECK_MODE:-stub}"
log "BUILD-STAGING: running staging_check (--mode $STAGING_CHECK_MODE) against fresh http://localhost:$TARGET_PORT inside $STAGING_CONTAINER"
if docker exec "$STAGING_CONTAINER" python3 "$STAGING_CHECK_PATH" \
--base-url "http://localhost:$TARGET_PORT" --mode "$STAGING_CHECK_MODE" >> "$LOG" 2>&1; then
log "BUILD-STAGING: staging_check PASS on fresh image (exit 0)"
exit 0
fi
log "BUILD-STAGING: staging_check FAILED on fresh image - artefact not promotable (exit 1)"
exit 1
fi
# ============================================================================
# NORMAL DEPLOY mode (--deploy or no argument)
# ============================================================================
@@ -139,10 +216,38 @@ else
log "No previous image captured (first deploy or service not running?)"
fi
# 2. Pull latest code
# 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"
git pull origin main >> "$LOG" 2>&1
# 2b. Build-once (ORCH-36): retag the prevalidated staging image onto TARGET_IMAGE
# instead of rebuilding, so prod runs the exact artefact that passed staging.
# Backward compatible: skipped when SOURCE_IMAGE is unset.
if [[ -n "$SOURCE_IMAGE" ]]; then
if docker image inspect "$SOURCE_IMAGE" >/dev/null 2>&1; then
# ORCH-58 (Strategy B): fail-closed provenance guard BEFORE docker tag.
# When EXPECTED_REVISION is set, SOURCE_IMAGE's git-commit label MUST match,
# else exit 1 (FAILED -> БАГ-8 rollback); prod is NEVER touched. Empty label
# / inspect error / mismatch all fail-close. Unset EXPECTED_REVISION -> no
# check (backward-compatible for non-self repos / legacy calls).
if [[ -n "$EXPECTED_REVISION" ]]; then
IMG_REV=$(docker image inspect --format "{{ index .Config.Labels \"$REVISION_LABEL\" }}" "$SOURCE_IMAGE" 2>/dev/null || true)
if [[ "$IMG_REV" == "<no value>" ]]; then IMG_REV=""; fi
if [[ -z "$IMG_REV" || "$IMG_REV" != "$EXPECTED_REVISION" ]]; then
log "PROVENANCE: SOURCE_IMAGE revision '$IMG_REV' != expected '$EXPECTED_REVISION' (fail-closed) - aborting (exit 1)"
exit 1
fi
log "PROVENANCE: SOURCE_IMAGE revision matches expected ($EXPECTED_REVISION) - retag allowed"
fi
log "BUILD-ONCE: retagging $SOURCE_IMAGE -> $TARGET_IMAGE (no rebuild)"
docker tag "$SOURCE_IMAGE" "$TARGET_IMAGE" >> "$LOG" 2>&1
else
log "BUILD-ONCE: SOURCE_IMAGE '$SOURCE_IMAGE' not found locally - aborting (exit 1)"
exit 1
fi
fi
# 3. Restart service
log "Starting $TARGET_SERVICE (profile=$COMPOSE_PROFILE)"
if [[ -n "$COMPOSE_PROFILE" ]]; then

View File

@@ -214,7 +214,14 @@ class AgentLauncher:
Same spawn path as launch(), but threads job['id'] through so the monitor
can update the job's status (done / requeue / failed) and link jobs.run_id
to the agent_runs row. Returns the agent_run_id.
ORCH-036: the reserved-agent ``deploy-finalizer`` is a DETERMINISTIC
(no-LLM) job — intercept it BEFORE _spawn (which would raise
"Unknown agent", R-6) and run the deploy finalizer synchronously, driving
the jobs row status itself. Returns None (no agent_run row).
"""
if job.get("agent") == "deploy-finalizer":
return self._run_deploy_finalizer_job(job)
return self._spawn(
job["agent"],
job["repo"],
@@ -223,6 +230,27 @@ class AgentLauncher:
job_id=job["id"],
)
def _run_deploy_finalizer_job(self, job: dict):
"""ORCH-036 Phase C: run the deterministic deploy finalizer for a job.
Not an LLM spawn — there is no subprocess/monitor, so we mark the jobs row
done/failed here. Any error is contained (the finalizer never-raises, but
we guard anyway so a finalizer fault can't wedge the worker).
"""
from ..db import mark_job
from .. import stage_engine
try:
stage_engine.run_deploy_finalizer(job)
mark_job(job["id"], "done")
logger.info(f"deploy-finalizer job {job['id']} done")
except Exception as e:
logger.error(f"deploy-finalizer job {job['id']} failed: {e}")
try:
mark_job(job["id"], "failed", error=f"deploy-finalizer error: {e}")
except Exception:
pass
return None
def _spawn(self, agent: str, repo: str, task_content: str = None,
task_id: int = None, job_id: int = None) -> int:
"""Shared spawn implementation for launch() and launch_job().

View File

@@ -152,6 +152,73 @@ class Settings(BaseSettings):
merge_defer_delay_s: int = 60
merge_defer_max_attempts: int = 5
# ORCH-036: executable self-deploy (deploy stage drives the host hook).
# The `deploy` stage for the self-hosting repo is turned into a REAL prod
# restart via a detached host process, gated by a manual approve. Three-phase
# design (ADR-001): A=approve-request, B=initiate (human Approved), C=finalizer
# maps the hook exit-code -> deploy_status. Non-self repos are unaffected.
#
# self_deploy_enabled -> global kill-switch; False -> no Phase A/B/C
# interception (the legacy synchronous deployer
# path runs for everyone, env ORCH_SELF_DEPLOY_ENABLED).
# self_deploy_repos -> CSV of repos where executable self-deploy is
# REAL; empty -> only the self-hosting repo
# (orchestrator). Mirrors merge_gate_repos.
# deploy_require_manual_approve -> require a human Approved before the prod
# restart (BR-5). Default true; NOT toggled in
# ORCH-36 (AC-12). false -> Phase A initiates
# immediately (structural branch, off by default).
# deploy_finalize_delay_s -> delay before the first finalize poll; must be
# > the hook health-loop (~60s) so the verdict
# usually exists on the first poll.
# deploy_finalize_max_attempts -> bounded finalize-defer budget (anti-livelock).
# ssh / hook target (detached prod restart; real values live on the host):
# deploy_ssh_user / deploy_ssh_host -> ssh target for the host hook (INFRA P-2).
# deploy_hook_script -> path to the hook ON THE HOST (relative to repo).
# deploy_host_repo_path -> orchestrator clone path on the host.
# prod overrides passed to the hook for build-once (retag staging image -> prod):
# deploy_prod_source_image -> image validated on staging (retagged, no rebuild).
# deploy_prod_target_service / _port / _image / _compose_profile -> prod profile.
# deploy_prod_prev_image_file -> prod prev-image snapshot (separate from staging).
self_deploy_enabled: bool = True
self_deploy_repos: str = ""
deploy_require_manual_approve: bool = True
deploy_finalize_delay_s: int = 90
deploy_finalize_max_attempts: int = 10
deploy_ssh_user: str = "slin"
deploy_ssh_host: str = ""
deploy_hook_script: str = "scripts/orchestrator-deploy-hook.sh"
deploy_host_repo_path: str = "/home/slin/repos/orchestrator"
deploy_prod_source_image: str = "orchestrator-orchestrator-staging"
deploy_prod_target_service: str = "orchestrator"
deploy_prod_target_port: int = 8500
deploy_prod_target_image: str = "orchestrator-orchestrator"
deploy_prod_compose_profile: str = ""
deploy_prod_prev_image_file: str = ".deploy-prev-image-prod"
# 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
# fresh — a guarantee the pipeline never had (a stale image could be silently
# promoted, LESSONS_ORCH-036 §4). Two complementary layers, self-hosting only:
# A (liveness): the QG sub-check check_staging_image_fresh rebuilds the
# staging image from the VALIDATED commit (worktree HEAD after merge-gate)
# and recreates 8501 on the deploy-staging -> deploy edge, so we validate
# and promote ONE artefact.
# B (safety): build_deploy_command passes EXPECTED_REVISION and the hook
# fail-closes (exit 1) if SOURCE_IMAGE's revision label != EXPECTED_REVISION
# before `docker tag`, making a silent stale promote structurally impossible.
#
# image_freshness_enabled -> SINGLE kill-switch for the WHOLE feature (A + B
# together; never "B without A" = a deadlock). False
# -> legacy ORCH-36 behaviour (BUILD-ONCE, no guard,
# no EXPECTED_REVISION). Env ORCH_IMAGE_FRESHNESS_ENABLED.
# image_freshness_repos -> CSV of repos where the feature is REAL; empty ->
# only the self-hosting repo (orchestrator). Mirrors
# self_deploy_repos / merge_gate_repos.
image_freshness_enabled: bool = True
image_freshness_repos: str = ""
# ORCH-053: stuck-task reconciler (sweeper for lost webhooks). A background
# daemon thread reconciles the "source of truth (gate / Plane) != task stage"
# drift left behind by a dropped webhook (502 on rebuild, no Plane/Gitea

333
src/image_freshness.py Normal file
View File

@@ -0,0 +1,333 @@
"""Staging-image provenance for the BUILD-ONCE retag to prod (ORCH-058).
ORCH-36 made the ``deploy`` stage promote the staging image to prod by a plain
``docker tag`` (BUILD-ONCE, no rebuild), assuming "the staging image is fresh and
built from the validated code". That guarantee never existed: nothing in the
pipeline rebuilt the staging image from the validated commit, so a STALE image
could be silently promoted — the most dangerous bootstrap bug of LESSONS_ORCH-036
(§4): a green deploy that quietly rolled prod back to 2-day-old code.
This module provides the deterministic (no-LLM) primitives that enforce the
``INV-FRESH`` invariant (ADR-001), as **two complementary layers** wired only for
self-hosting:
* **A — liveness:** :func:`check_staging_image_fresh` is a QG sub-check on the
``deploy-staging -> deploy`` edge (composed by ``stage_engine`` AFTER the
merge-gate, BEFORE Phase A). It rebuilds ``orchestrator-orchestrator-staging``
from the VALIDATED commit (worktree HEAD after the merge-gate rebase), recreates
the 8501 container, and runs ``staging_check.py --mode stub`` against that fresh
8501 (ADR-001 step 3), so we validate exactly the ONE artefact later retagged to
prod (AC-4). FAIL -> rollback to ``development`` (mirrors the merge-gate).
* **B — safety:** :func:`expected_revision` feeds the validated SHA to
``self_deploy.build_deploy_command`` as ``EXPECTED_REVISION``; the host hook
fail-closes (``exit 1``) before ``docker tag`` if the SOURCE_IMAGE revision
label does not match. :func:`provenance_verdict` is the PURE verdict logic
that mirrors the hook's comparison (unit-tested in isolation).
Both layers share ONE anchor — :func:`validated_revision` — so the build stamp (A)
and the expected revision (B) can never diverge.
This module is a **leaf**: it imports only ``config`` / ``git_worktree`` and lazily
``qg.checks.is_self_hosting_repo``; it never imports ``stage_engine`` /
``self_deploy``. Every public helper honours a strict **never-raise** contract and
is **fail-closed** on any doubt (missing label, empty SHA, docker/ssh/inspect
error) -> treated as a mismatch, never promoted "on faith".
"""
import logging
import os
import shlex
import subprocess
from .config import settings
logger = logging.getLogger("orchestrator.image_freshness")
# The OCI-standard label key carrying the build commit (Dockerfile stamps it).
REVISION_LABEL = "org.opencontainers.image.revision"
# Bounded timeouts so a hung git/docker/ssh never wedges the monitor-thread.
_GIT_TIMEOUT = 30
_INSPECT_TIMEOUT = 30
# The remote rebuild (docker build + compose recreate + health + staging_check) is
# the slow path; keep it generous but bounded (mirrors the merge-gate re-test order).
_REBUILD_TIMEOUT = 1200
# Explicit STAGING target for the --build-staging rebuild (Strategy A). These mirror
# the hook's staging-safe defaults but are passed EXPLICITLY so a future change to the
# hook defaults can never silently retarget the self-rebuild at prod (8500) — the whole
# path builds/recreates STAGING ONLY (AC-9, review P2). Never the prod 8500 target.
_STAGING_SERVICE = "orchestrator-staging"
_STAGING_PORT = 8501
_STAGING_COMPOSE_PROFILE = "staging"
# ---------------------------------------------------------------------------
# Conditionality (mirrors self_deploy_applies / _merge_gate_applies)
# ---------------------------------------------------------------------------
def image_freshness_applies(repo: str) -> bool:
"""Whether the staging-image provenance feature (A + B) is REAL for this repo.
Mirrors the ORCH-35 / ORCH-43 / ORCH-36 conditional rollout:
* ``image_freshness_enabled=False`` -> always False (single kill-switch for
the WHOLE feature; legacy ORCH-36 BUILD-ONCE behaviour for everyone).
* ``image_freshness_repos`` (CSV) non-empty -> real only for listed repos.
* empty CSV -> real ONLY for the self-hosting repo (``orchestrator``).
Never raises.
"""
try:
if not settings.image_freshness_enabled:
return False
raw = (settings.image_freshness_repos or "").strip()
if raw:
allowed = {r.strip().lower() for r in raw.split(",") if r.strip()}
return (repo or "").strip().lower() in allowed
# Lazy import keeps this module a leaf (avoids importing qg at module load).
from .qg.checks import is_self_hosting_repo
return is_self_hosting_repo(repo)
except Exception as e: # noqa: BLE001 - never-raise contract
logger.warning("image_freshness_applies error for %s: %s", repo, e)
return False
# ---------------------------------------------------------------------------
# The validated-commit anchor (single source for both A and B)
# ---------------------------------------------------------------------------
def validated_revision(repo: str, branch: str) -> str:
"""Return the SHA of the VALIDATED commit = ``git rev-parse HEAD`` in the task
worktree AFTER the merge-gate (post auto-rebase + push --force-with-lease).
This is exactly the tree the merge-gate re-tested green and that merges into
``main``. It is the SINGLE anchor that feeds both the staging rebuild stamp (A)
and the expected revision passed to the hook (B), so the two layers cannot
disagree about "what commit prod must run".
Fail-closed / never-raise (AC-3 / AC-8): a missing worktree or any git/OS error
returns ``""`` (an empty SHA, which downstream treats as a provenance mismatch),
never a propagated exception.
"""
from .git_worktree import get_worktree_path
try:
wt = get_worktree_path(repo, branch)
except Exception as e: # noqa: BLE001 - never-raise contract
logger.warning("validated_revision: worktree error for %s/%s: %s", repo, branch, e)
return ""
if not os.path.isdir(wt):
logger.warning("validated_revision: no worktree at %s for %s/%s", wt, repo, branch)
return ""
try:
r = subprocess.run(
["git", "-C", wt, "rev-parse", "HEAD"],
capture_output=True, text=True, timeout=_GIT_TIMEOUT,
)
except (subprocess.SubprocessError, OSError) as e:
logger.warning("validated_revision: git error for %s/%s: %s", repo, branch, e)
return ""
if r.returncode != 0:
logger.warning(
"validated_revision: rev-parse rc=%s for %s/%s", r.returncode, repo, branch
)
return ""
return (r.stdout or "").strip()
def expected_revision(repo: str, branch: str) -> str:
"""The revision the hook must require (Strategy B), or ``""`` when the feature
is inactive for this repo.
Returns :func:`validated_revision` ONLY when :func:`image_freshness_applies`
(so non-self / disabled callers get ``""`` -> the hook keeps its backward-
compatible "no provenance check" behaviour, no EXPECTED_REVISION env). The
config invariant (ADR-001) is that B is active iff A is active — both gated by
the SAME flag — so there is never a "B without A" deadlock. Never raises.
"""
try:
if not image_freshness_applies(repo):
return ""
return validated_revision(repo, branch)
except Exception as e: # noqa: BLE001 - never-raise contract
logger.warning("expected_revision error for %s/%s: %s", repo, branch, e)
return ""
# ---------------------------------------------------------------------------
# Pure provenance verdict (mirrors the hook's bash comparison — Strategy B)
# ---------------------------------------------------------------------------
def provenance_verdict(expected_sha: str, image_sha: str) -> tuple[bool, str]:
"""Pure, deterministic provenance verdict (no I/O) — the Python mirror of the
hook's fail-closed comparison (Strategy B), unit-testable in isolation.
Contract (AC-1 / AC-2 / AC-3, fail-closed):
* both non-empty AND equal -> ``(True, "provenance match: <sha12>")``.
* expected empty / image empty -> ``(False, "...")`` — fail-closed: a
missing expected SHA or an unlabelled image is NEVER treated as fresh.
* both non-empty but different -> ``(False, "provenance mismatch ...")``.
"""
exp = (expected_sha or "").strip()
img = (image_sha or "").strip()
if not exp:
return False, "provenance fail-closed: empty expected revision"
if not img:
return False, "provenance fail-closed: image has no revision label"
if exp == img:
return True, f"provenance match: {exp[:12]}"
return False, f"provenance mismatch: image {img[:12]} != expected {exp[:12]}"
def image_revision(image: str, ssh_target: str | None = None) -> str:
"""Read an image's ``org.opencontainers.image.revision`` label via
``docker image inspect``. Returns ``""`` on any error or when the label is
absent (fail-closed -> downstream treats it as a mismatch).
``docker`` lives on the HOST (the container ships only ``openssh-client git``),
so when ``ssh_target`` is given the inspect runs over ssh; otherwise it runs
locally (covers host-side callers and tests). Never raises (AC-8).
"""
fmt = '{{ index .Config.Labels "%s" }}' % REVISION_LABEL
local_cmd = ["docker", "image", "inspect", "--format", fmt, image]
if ssh_target:
remote = "docker image inspect --format " + shlex.quote(fmt) + " " + shlex.quote(image)
cmd = ["ssh", "-o", "StrictHostKeyChecking=no", ssh_target, remote]
else:
cmd = local_cmd
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=_INSPECT_TIMEOUT)
except (subprocess.SubprocessError, OSError) as e:
logger.warning("image_revision: inspect error for %s: %s", image, e)
return ""
if r.returncode != 0:
logger.warning("image_revision: inspect rc=%s for %s", r.returncode, image)
return ""
out = (r.stdout or "").strip()
# `docker inspect` prints "<no value>" for a missing label key.
if out in ("", "<no value>"):
return ""
return out
# ---------------------------------------------------------------------------
# Staging rebuild from the validated commit (Strategy A) — host-side via the hook
# ---------------------------------------------------------------------------
def _ssh_target() -> str | None:
"""ssh ``user@host`` for the host rebuild, or None when no host is configured
(tests / non-self contexts that mock this away)."""
host = (settings.deploy_ssh_host or "").strip()
if not host:
return None
user = (settings.deploy_ssh_user or "").strip()
return f"{user}@{host}" if user else host
def _host_worktree_path(repo: str, branch: str) -> str:
"""The task worktree path AS SEEN FROM THE HOST (docker build context).
The container path uses ``settings.worktrees_dir`` (under ``repos_dir``); the
host sees the same files under ``host_repos_dir``. Derive the host path by
swapping the mount prefix (mirrors ``self_deploy.host_state_dir``).
"""
from .git_worktree import get_worktree_path
container_wt = get_worktree_path(repo, branch)
repos_dir = settings.repos_dir.rstrip("/")
host_repos_dir = settings.host_repos_dir.rstrip("/")
if container_wt.startswith(repos_dir):
return host_repos_dir + container_wt[len(repos_dir):]
return container_wt
def rebuild_staging_image(repo: str, branch: str, sha: str) -> tuple[bool, str]:
"""Rebuild the staging image from the VALIDATED commit and recreate 8501
(Strategy A) by invoking the host hook in ``--build-staging`` mode over ssh.
The hook (``orchestrator-deploy-hook.sh --build-staging``) runs, on the host:
``docker build --build-arg GIT_SHA=<sha> -t <staging-image> <host-worktree>``
-> ``docker compose --profile staging up -d --no-build orchestrator-staging``
-> health-check 8501
-> ``staging_check.py --mode stub`` against the FRESH 8501 (ADR-001 step 3,
AC-4: validate exactly the artefact later retagged to prod).
Same exit-code contract (0 = ok). This trades prod for staging ONLY (8501),
NEVER prod (8500) (AC-9): all build/recreate/validate targets are the staging
service — passed EXPLICITLY below, not left to hook defaults (review P2).
Synchronous ssh is fine here (unlike Phase B): recreating staging does not kill
the prod worker running this code. Bounded by ``_REBUILD_TIMEOUT``.
Returns ``(True, msg)`` on a healthy rebuild, else ``(False, reason)``.
Never raises (AC-8).
"""
target = _ssh_target()
if not target:
return False, "no ssh host configured for staging rebuild"
host_ctx = _host_worktree_path(repo, branch)
# Pass the STAGING target explicitly (service/port/profile/container), so the
# rebuild + recreate + staging_check can never drift onto the prod 8500 service
# even if the hook's defaults change (AC-9, review P2). STAGING_CONTAINER is the
# container staging_check is docker-exec'd inside (step 3b).
env_assignments = (
f"GIT_SHA={shlex.quote(sha)} "
f"BUILD_CONTEXT={shlex.quote(host_ctx)} "
f"TARGET_IMAGE={shlex.quote(settings.deploy_prod_source_image)} "
f"TARGET_SERVICE={shlex.quote(_STAGING_SERVICE)} "
f"TARGET_PORT={shlex.quote(str(_STAGING_PORT))} "
f"COMPOSE_PROFILE={shlex.quote(_STAGING_COMPOSE_PROFILE)} "
f"STAGING_CONTAINER={shlex.quote(_STAGING_SERVICE)}"
)
inner = (
f"cd {shlex.quote(settings.deploy_host_repo_path)} && "
f"{env_assignments} "
f"bash {shlex.quote(settings.deploy_hook_script)} --build-staging"
)
cmd = ["ssh", "-o", "StrictHostKeyChecking=no", target, inner]
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=_REBUILD_TIMEOUT)
except subprocess.TimeoutExpired:
return False, f"staging rebuild timeout after {_REBUILD_TIMEOUT}s"
except (subprocess.SubprocessError, OSError) as e:
return False, f"staging rebuild ssh error: {e}"
if r.returncode != 0:
detail = ((r.stderr or "") + (r.stdout or "")).strip()[-200:]
return False, f"staging rebuild failed (rc={r.returncode}): {detail}"
logger.info("rebuild_staging_image: %s/%s rebuilt from %s and healthy", repo, branch, sha[:12])
return True, f"staging rebuilt from {sha[:12]} and healthy"
# ---------------------------------------------------------------------------
# QG sub-check: check_staging_image_fresh (Strategy A liveness, AC-4/AC-6)
# ---------------------------------------------------------------------------
def check_staging_image_fresh(repo: str, work_item_id: str, branch: str) -> tuple[bool, str]:
"""ORCH-058 freshness sub-gate on the ``deploy-staging -> deploy`` edge.
Deterministic, no LLM. Mirrors ``check_branch_mergeable`` (ORCH-043):
1. Conditionality: ``image_freshness_enabled=False`` -> ``(True, "...disabled")``;
a repo the feature is not real for -> ``(True, "image-freshness N/A for <repo>")``.
2. Anchor: ``sha = validated_revision(repo, branch)``. Empty -> fail-closed
``(False, ...)`` (AC-3): we never rebuild/promote without a known commit.
3. Rebuild the staging image from that commit, recreate 8501, and run
``staging_check.py --mode stub`` against the fresh 8501 (host hook). PASS ->
``(True, ...)``: the artefact we just validated (build + e2e) is the exact
one that will be retagged to prod (AC-4, loop closed). FAIL -> ``(False, ...)``
-> the engine rolls back to ``development`` (AC-2).
Never-raise (AC-8): any internal error -> ``(False, "<reason>")``; an exception
never escapes into ``advance_stage``. Returns ``(True, "N/A")`` for non-self
repos so the deploy edge is unchanged for them (AC-5).
"""
try:
if not settings.image_freshness_enabled:
return True, "image-freshness disabled"
if not image_freshness_applies(repo):
return True, f"image-freshness N/A for {repo}"
sha = validated_revision(repo, branch)
if not sha:
# Fail-closed: without the validated commit we cannot prove freshness.
return False, "cannot resolve validated revision (fail-closed)"
ok, reason = rebuild_staging_image(repo, branch, sha)
if not ok:
return False, f"staging rebuild failed: {reason}"
return True, f"staging image fresh ({sha[:12]})"
except Exception as e: # noqa: BLE001 - never-raise contract
logger.error("check_staging_image_fresh error for %s/%s: %s", repo, branch, e)
return False, f"image-freshness error: {e}"

View File

@@ -702,6 +702,20 @@ def check_branch_mergeable(repo: str, work_item_id: str, branch: str) -> tuple[b
return False, f"merge-gate error: {e}"
def _check_staging_image_fresh(repo: str, work_item_id: str, branch: str) -> tuple[bool, str]:
"""ORCH-058 freshness sub-gate (Strategy A) on the deploy-staging -> deploy edge.
Thin registry wrapper that delegates to ``image_freshness.check_staging_image_fresh``
(rebuild the staging image from the validated commit + recreate 8501). The real
logic lives in ``src/image_freshness.py`` (leaf module, never-raise, fail-closed);
importing it lazily here avoids an import cycle (image_freshness imports
is_self_hosting_repo from this module). For non-self repos it returns
``(True, "N/A")`` so the deploy edge is unchanged for them (AC-5).
"""
from ..image_freshness import check_staging_image_fresh
return check_staging_image_fresh(repo, work_item_id, branch)
# Registry for dynamic lookup by name
QG_CHECKS = {
"check_analysis_approved": check_analysis_approved,
@@ -715,4 +729,5 @@ QG_CHECKS = {
"check_deploy_status": check_deploy_status,
"check_staging_status": check_staging_status,
"check_branch_mergeable": check_branch_mergeable,
"check_staging_image_fresh": _check_staging_image_fresh,
}

351
src/self_deploy.py Normal file
View File

@@ -0,0 +1,351 @@
"""Executable self-deploy primitives (ORCH-036).
The ``deploy`` stage for the self-hosting ``orchestrator`` repo is a REAL prod
restart, not a paper LLM verdict. Because the prod container (8500) runs the
worker/agent itself, the restart must be performed by an EXTERNAL host process
that survives the container dying (BR-2). The orchestration is split into three
deterministic phases (ADR-001), wired in ``stage_engine``:
* Phase A — request approve on the ``deploy-staging -> deploy`` edge.
* Phase B — a human Plane ``Approved`` initiates the detached host deploy.
* Phase C — a deterministic finalizer maps the hook exit-code -> deploy_status.
This module is a **leaf**: it imports only config / git_worktree (and lazily
``qg.checks.is_self_hosting_repo``), never ``stage_engine`` / ``launcher`` — the
orchestration that needs those lives in ``stage_engine``. Every public helper
honours a **never-raise** contract so a deploy-state hiccup can never crash the
stage engine.
Restart-safe state lives in sentinel files under
``<repos_dir>/.deploy-state-<repo>/<work_item_id>/`` (mirrors the merge-lease
pattern, ТЗ §4 — no DB migration), on the shared mount visible to BOTH the
container (reads markers) and the host (writes ``result``):
* ``approve-requested`` — Phase A done;
* ``initiated`` — Phase B started (idempotency-guard);
* ``result`` — the hook exit-code, written by the host WRAPPER
(``echo $? > result``), NOT by the hook itself.
"""
import logging
import os
import shlex
import shutil
import subprocess
from .config import settings
logger = logging.getLogger("orchestrator.self_deploy")
# Sentinel marker filenames (see module docstring).
APPROVE_REQUESTED = "approve-requested"
INITIATED = "initiated"
RESULT = "result"
# ssh launch is detached (returns immediately); keep a bounded timeout so a hung
# ssh handshake never wedges the caller.
_SSH_TIMEOUT = 30
_GIT_TIMEOUT = 60
# ---------------------------------------------------------------------------
# Conditionality
# ---------------------------------------------------------------------------
def self_deploy_applies(repo: str) -> bool:
"""Whether executable self-deploy (Phase A/B/C) is REAL for this repo.
Mirrors the ORCH-35 / ORCH-43 conditional rollout:
* ``self_deploy_enabled=False`` -> always False (global kill-switch); the
legacy synchronous deployer path runs for everyone.
* ``self_deploy_repos`` (CSV) non-empty -> real only for listed repos.
* empty CSV -> real ONLY for the self-hosting repo (``orchestrator``).
Never raises.
"""
try:
if not settings.self_deploy_enabled:
return False
raw = (settings.self_deploy_repos or "").strip()
if raw:
allowed = {r.strip().lower() for r in raw.split(",") if r.strip()}
return (repo or "").strip().lower() in allowed
# Lazy import keeps this module a leaf (avoids importing qg at module load).
from .qg.checks import is_self_hosting_repo
return is_self_hosting_repo(repo)
except Exception as e: # noqa: BLE001 - never-raise contract
logger.warning("self_deploy_applies error for %s: %s", repo, e)
return False
# ---------------------------------------------------------------------------
# exit-code -> deploy_status mapping (pure, unit-tested: TC-01/02/03)
# ---------------------------------------------------------------------------
def map_exit_code_to_status(exit_code) -> str:
"""Map a deploy-hook exit-code to a machine verdict (deterministic, pure).
Contract (AC-1 / AC-3, hook exit-code contract 0/1/2):
* ``0`` -> ``"SUCCESS"`` (health-ok proven by the hook).
* ``1`` (rolled back), ``2`` (rollback also failed), anything else, or a
non-int/None -> ``"FAILED"`` (fail-closed; never advances on doubt).
"""
try:
code = int(exit_code)
except (TypeError, ValueError):
return "FAILED"
return "SUCCESS" if code == 0 else "FAILED"
def build_deploy_log(work_item_id: str, exit_code, status: str) -> str:
"""Render a 14-deploy-log.md body whose ``deploy_status:`` frontmatter is the
verdict ``check_deploy_status`` / ``_parse_deploy_status`` reads (contract
unchanged, AC-10). The body is informational only — only the frontmatter is
machine-read.
"""
return (
"---\n"
f"deploy_status: {status}\n"
f"work_item: {work_item_id}\n"
f"hook_exit_code: {exit_code}\n"
"deployed_by: deploy-finalizer\n"
"---\n\n"
"# Deploy log — ORCH-036 executable self-deploy\n\n"
f"Прод-деплой завершён хост-хуком с exit-code `{exit_code}` -> "
f"`deploy_status: {status}`.\n\n"
"Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM.\n"
)
# ---------------------------------------------------------------------------
# Sentinel state (restart-safe, no DB migration — ТЗ §4)
# ---------------------------------------------------------------------------
def _state_dir(base: str, repo: str, work_item_id: str | None) -> str:
return os.path.join(base, f".deploy-state-{repo}", (work_item_id or "_"))
def container_state_dir(repo: str, work_item_id: str | None) -> str:
"""State dir as seen FROM THE CONTAINER (settings.repos_dir mount)."""
return _state_dir(settings.repos_dir, repo, work_item_id)
def host_state_dir(repo: str, work_item_id: str | None) -> str:
"""State dir as seen FROM THE HOST (settings.host_repos_dir).
Same physical directory as ``container_state_dir`` via the shared mount; the
host path is what we embed in the ssh command so the host wrapper writes the
``result`` sentinel where the container can read it.
"""
return _state_dir(settings.host_repos_dir, repo, work_item_id)
def marker_path(repo: str, work_item_id: str | None, name: str) -> str:
return os.path.join(container_state_dir(repo, work_item_id), name)
def has_marker(repo: str, work_item_id: str | None, name: str) -> bool:
"""True iff the named sentinel exists. Never raises."""
try:
return os.path.isfile(marker_path(repo, work_item_id, name))
except Exception as e: # noqa: BLE001 - never-raise
logger.warning("has_marker error for %s/%s/%s: %s", repo, work_item_id, name, e)
return False
def write_marker(repo: str, work_item_id: str | None, name: str, content: str = "") -> bool:
"""Create/overwrite a sentinel (best-effort). Returns True on success."""
try:
d = container_state_dir(repo, work_item_id)
os.makedirs(d, exist_ok=True)
with open(os.path.join(d, name), "w", encoding="utf-8") as f:
f.write(str(content))
return True
except OSError as e:
logger.warning("write_marker error for %s/%s/%s: %s", repo, work_item_id, name, e)
return False
def clear_state(repo: str, work_item_id: str | None) -> bool:
"""Remove ALL deploy-state sentinels for this work item (best-effort).
Sentinels are keyed by ``work_item_id`` (stable for the whole task lifetime),
so a FAILED prod-deploy leaves ``approve-requested`` / ``initiated`` / ``result``
behind. Without cleanup, after the БАГ-8 rollback (deploy -> development) and a
fix, the task reaching ``deploy`` again would hit Phase B's idempotency-guard:
the STALE ``initiated`` makes it a no-op, the detached hook never re-launches and
the task wedges on ``deploy`` forever (re-deploy-after-rollback contract broken;
AC-4/AC-10). A stale ``result`` would likewise be mis-read by the new finalizer.
Clearing the whole state dir restores a clean slate for the next pass. Idempotent
(a missing dir is success). Never raises.
"""
d = container_state_dir(repo, work_item_id)
try:
shutil.rmtree(d)
logger.info("clear_state: removed deploy-state dir %s", d)
return True
except FileNotFoundError:
return True
except OSError as e: # noqa: BLE001 - never-raise contract
logger.warning("clear_state error for %s/%s: %s", repo, work_item_id, e)
return False
def read_result(repo: str, work_item_id: str | None) -> tuple[bool, int | None]:
"""Read the ``result`` sentinel (hook exit-code written by the host wrapper).
Returns ``(present, exit_code)``:
* ``(False, None)`` -> not written yet (finalizer should DEFER);
* ``(True, <int>)`` -> verdict ready;
* ``(True, 1)`` -> present but corrupt/unparseable -> treated as a
failure code (fail-closed) so we never advance on garbage.
Never raises.
"""
p = marker_path(repo, work_item_id, RESULT)
try:
with open(p, "r", encoding="utf-8") as f:
raw = f.read().strip()
except FileNotFoundError:
return False, None
except OSError as e:
logger.warning("read_result error for %s/%s: %s", repo, work_item_id, e)
return False, None
if raw == "":
return False, None
try:
return True, int(raw)
except ValueError:
logger.warning("read_result: corrupt result %r for %s/%s", raw, repo, work_item_id)
return True, 1
# ---------------------------------------------------------------------------
# Detached host deploy: ssh + setsid (Phase B)
# ---------------------------------------------------------------------------
def build_deploy_command(repo: str, work_item_id: str | None, branch: str) -> list[str]:
"""Build the ssh argv that launches the DETACHED prod deploy on the host.
The remote command runs the hook via ``setsid`` with stdin/stdout detached and
backgrounded (``&``) so the process SURVIVES the prod container restart (BR-2),
then the WRAPPER (not the hook) writes the exit-code to the ``result`` sentinel:
setsid bash -c 'cd <repo> && <prod env...> bash <hook> --deploy; \
echo $? > <result>' >> <hook.log> 2>&1 </dev/null &
Build-once (BR-6): ``SOURCE_IMAGE=<staging-image>`` makes the hook retag the
staging-validated image to the prod tag instead of rebuilding (no ``docker
build``). The exit-code contract of the hook is untouched.
Provenance guard (ORCH-058, Strategy B): when the image-freshness feature is
active for this repo, the VALIDATED commit SHA is passed as
``EXPECTED_REVISION=<sha>`` so the hook fail-closes (``exit 1``) before
``docker tag`` if SOURCE_IMAGE's revision label does not match — a stale image
can never be silently promoted. When inactive (non-self / kill-switch off)
``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
host_dir = host_state_dir(repo, work_item_id)
result_sentinel = os.path.join(host_dir, RESULT)
hook_log = os.path.join(host_dir, "hook.log")
env_assignments = (
f"SOURCE_IMAGE={shlex.quote(settings.deploy_prod_source_image)} "
f"TARGET_SERVICE={shlex.quote(settings.deploy_prod_target_service)} "
f"TARGET_PORT={int(settings.deploy_prod_target_port)} "
f"TARGET_IMAGE={shlex.quote(settings.deploy_prod_target_image)} "
f"COMPOSE_PROFILE={shlex.quote(settings.deploy_prod_compose_profile)} "
f"PREV_IMAGE_FILE={shlex.quote(settings.deploy_prod_prev_image_file)}"
)
expected_rev = image_freshness.expected_revision(repo, branch)
if expected_rev:
env_assignments += f" EXPECTED_REVISION={shlex.quote(expected_rev)}"
inner = (
f"cd {shlex.quote(settings.deploy_host_repo_path)} && "
f"{env_assignments} "
f"bash {shlex.quote(settings.deploy_hook_script)} --deploy; "
f"echo $? > {shlex.quote(result_sentinel)}"
)
remote = (
f"setsid bash -c {shlex.quote(inner)} "
f">> {shlex.quote(hook_log)} 2>&1 </dev/null &"
)
user = (settings.deploy_ssh_user or "").strip()
host = (settings.deploy_ssh_host or "").strip()
target = f"{user}@{host}" if user else host
return ["ssh", "-o", "StrictHostKeyChecking=no", target, remote]
def initiate_deploy(repo: str, work_item_id: str | None, branch: str) -> tuple[bool, str]:
"""Launch the detached prod deploy on the host (Phase B). Never raises.
The ssh call returns immediately (the remote process is detached via setsid +
``&``). Returns ``(True, msg)`` when ssh dispatched the detached process, or
``(False, reason)`` so the caller can alert and let the human re-approve.
"""
# Ensure the shared state dir exists so the host wrapper can write `result`.
try:
os.makedirs(container_state_dir(repo, work_item_id), exist_ok=True)
except OSError as e:
logger.warning("initiate_deploy: state dir error for %s/%s: %s", repo, work_item_id, e)
cmd = build_deploy_command(repo, work_item_id, branch)
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=_SSH_TIMEOUT)
except subprocess.TimeoutExpired:
return False, "ssh launch timeout"
except (subprocess.SubprocessError, OSError) as e:
return False, f"ssh launch error: {e}"
if r.returncode != 0:
detail = ((r.stderr or "") + (r.stdout or "")).strip()[:200]
return False, f"ssh launch failed (rc={r.returncode}): {detail}"
logger.info("initiate_deploy: detached prod deploy dispatched for %s/%s", repo, work_item_id)
return True, "deploy initiated (detached host process)"
# ---------------------------------------------------------------------------
# Deploy log write + best-effort merge (Phase C)
# ---------------------------------------------------------------------------
def write_deploy_log(repo: str, work_item_id: str, branch: str, exit_code, status: str) -> bool:
"""Write 14-deploy-log.md into the task worktree (so check_deploy_status reads
it) and best-effort commit+push it. Returns True iff the file was written.
Never raises.
"""
from .git_worktree import get_worktree_path
rel = f"docs/work-items/{work_item_id}/14-deploy-log.md"
try:
wt = get_worktree_path(repo, branch)
except Exception as e: # noqa: BLE001 - never-raise
logger.error("write_deploy_log: worktree error for %s/%s: %s", repo, branch, e)
return False
path = os.path.join(wt, rel)
content = build_deploy_log(work_item_id, exit_code, status)
try:
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
f.write(content)
except OSError as e:
logger.error("write_deploy_log: write error at %s: %s", path, e)
return False
# Best-effort commit + push (the gate also falls back to origin/main).
git_env = {
**os.environ,
"HOME": "/home/slin",
"GIT_AUTHOR_NAME": "deploy-finalizer",
"GIT_AUTHOR_EMAIL": "deploy-finalizer@mva154.local",
"GIT_COMMITTER_NAME": "deploy-finalizer",
"GIT_COMMITTER_EMAIL": "deploy-finalizer@mva154.local",
}
try:
subprocess.run(["git", "-C", wt, "add", rel],
capture_output=True, timeout=_GIT_TIMEOUT, env=git_env)
commit = subprocess.run(
["git", "-C", wt, "commit", "-m",
f"deploy(ORCH-036): finalize {status} for {work_item_id}"],
capture_output=True, text=True, timeout=_GIT_TIMEOUT, env=git_env,
)
if commit.returncode == 0:
subprocess.run(["git", "-C", wt, "push", "origin", branch],
capture_output=True, timeout=_GIT_TIMEOUT, env=git_env)
except (subprocess.SubprocessError, OSError) as e:
logger.warning("write_deploy_log: git commit/push best-effort failed: %s", e)
return True

View File

@@ -27,6 +27,7 @@ Agent-selection bug fix (ORCH-4):
import logging
import os
import time
from dataclasses import dataclass, field
from .db import get_db, update_task_stage, enqueue_job
@@ -35,6 +36,7 @@ from .git_worktree import get_worktree_path
from .review_parse import extract_review_findings, extract_test_failures
from .qg.checks import QG_CHECKS
from . import merge_gate
from . import self_deploy
from .notifications import (
notify_stage_change,
notify_qg_failure,
@@ -190,6 +192,23 @@ def advance_stage(
result.note = "terminal"
return result
# --- ORCH-036 Phase B: human Approved on `deploy` -> initiate deploy --
# A human flipping the Plane status to Approved on the `deploy` stage
# (finished_agent is None) is the prod-deploy trigger for the self-hosting
# repo. Initiate the DETACHED host deploy + enqueue the finalizer and
# return WITHOUT running check_deploy_status (the verdict does not exist
# yet — running the gate now would read a stale/absent log and falsely
# roll back, R-2). The finalizer (Phase C, finished_agent="deployer")
# records the verdict later; that path is NOT intercepted here.
if (
current_stage == "deploy"
and finished_agent is None
and settings.deploy_require_manual_approve
and self_deploy.self_deploy_applies(repo)
):
_handle_self_deploy_phase_b(task_id, repo, work_item_id, branch, result)
return result
# --- Quality gate ----------------------------------------------------
if qg_name and qg_name in QG_CHECKS:
# Human-approval gate: split by path.
@@ -252,6 +271,33 @@ def advance_stage(
):
return result
# --- ORCH-058 freshness sub-gate (deploy-staging -> deploy edge) ---
# AFTER the merge-gate finalised the validated HEAD and BEFORE Phase A.
# Rebuilds the staging image from that validated commit + recreates 8501
# so the artefact we validate is the exact one promoted to prod (AC-4).
# FAIL -> rollback to development (mirrors the merge-gate). Like the
# merge-gate it owns the outcome on intervention.
if _handle_image_freshness(
task_id, current_stage, repo, work_item_id, branch, agent, result
):
return result
# --- ORCH-036 Phase A: request approve before the prod deploy ---------
# On the deploy-staging -> deploy edge, AFTER a green check_staging_status
# and the merge-gate, the self-hosting repo does NOT auto-launch a prod
# deployer. Instead advance the STAGE to `deploy`, put the issue into an
# approval-pending state and wait for a human Approved (Phase B). The
# merge lease stays HELD across the wait (released on done / rollback).
if (
current_stage == "deploy-staging"
and settings.deploy_require_manual_approve
and self_deploy.self_deploy_applies(repo)
):
_handle_self_deploy_phase_a(
task_id, current_stage, repo, work_item_id, branch, result
)
return result
# --- Advance ---------------------------------------------------------
update_task_stage(task_id, next_stage)
# Telegram live tracker: the analysis->architecture advance is the human
@@ -656,6 +702,16 @@ def _handle_qg_failure_rollbacks(
notify_stage_change(task_id, current_stage, "development")
plane_notify_stage(work_item_id, current_stage, "development")
result.rolled_back_to = "development"
# ORCH-036: clear the deploy-state sentinels (approve-requested / initiated /
# result) so the NEXT prod-deploy pass (after the developer fixes and the task
# returns to `deploy`) is not wedged by Phase B's idempotency-guard reading a
# STALE `initiated`, nor the finalizer mis-reading a STALE `result`. Markers are
# keyed by work_item_id (stable across the rollback), so without this they
# survive into the retry and break re-deploy-after-rollback (AC-4/AC-10).
try:
self_deploy.clear_state(repo, work_item_id)
except Exception as e: # noqa: BLE001 - defensive (clear_state never-raises anyway)
logger.warning(f"Task {task_id}: deploy-state clear on deploy-fail failed: {e}")
# ORCH-043: deploy failed -> no merge will complete; release the lease so the
# next task isn't blocked until the lease ages out (holder-aware no-op).
try:
@@ -831,3 +887,282 @@ def _handle_merge_gate_rollback(
f"Task {task_id}: merge-gate FAILED, rolled back deploy-staging -> "
f"development ({reason})"
)
# ---------------------------------------------------------------------------
# ORCH-058: staging-image freshness sub-gate on the deploy-staging -> deploy edge
# ---------------------------------------------------------------------------
def _handle_image_freshness(
task_id, current_stage, repo, work_item_id, branch, agent, result: AdvanceResult
) -> bool:
"""Run check_staging_image_fresh on the deploy-staging -> deploy edge (ORCH-058).
Runs AFTER the merge-gate (validated HEAD finalised) and BEFORE Phase A. The
sub-check rebuilds the staging image from the validated commit + recreates 8501;
a green result means the artefact we validate is the exact one that will be
BUILD-ONCE retagged to prod (AC-4).
Returns True if the gate INTERVENED (the caller must return without advancing):
* FAIL (stale / rebuild error / fail-closed) -> ROLLBACK to development
(+ developer retry, capped by MAX_DEVELOPER_RETRIES) and RELEASE the merge
lease (the merge-gate held it on its PASS). Mirrors the merge-gate rollback.
Returns False when the gate PASSED (fresh, or N/A for a non-self repo) so
advance_stage proceeds to Phase A. On a PASS the merge lease stays HELD until
the actual merge (released on PR-merged webhook / deploy->done / rollback).
"""
passed, reason = _run_qg("check_staging_image_fresh", repo, work_item_id, branch)
if passed:
logger.info(f"Task {task_id}: image-freshness passed ({reason})")
return False
result.qg_name = "check_staging_image_fresh"
result.qg_passed = False
result.qg_reason = reason
update_task_stage(task_id, "development")
notify_stage_change(task_id, current_stage, "development")
plane_notify_stage(work_item_id, current_stage, "development")
result.rolled_back_to = "development"
set_issue_in_progress(work_item_id)
# The merge-gate held the lease on its PASS; freshness failed before the merge,
# so release it (holder-aware no-op if a different task already owns it).
try:
merge_gate.release_merge_lease(repo, branch)
except Exception as e: # noqa: BLE001 - defensive
logger.warning(f"Task {task_id}: merge-lease release on image-freshness fail failed: {e}")
notify_qg_failure(task_id, current_stage, "check_staging_image_fresh", reason)
plane_add_comment(
work_item_id,
f"❌ Staging-образ не свеж ({reason}). Откат на development. "
f"Developer нужен для фикса.",
author="deployer",
)
retry_count = _developer_retry_count(task_id)
if retry_count < MAX_DEVELOPER_RETRIES:
task_desc = (
f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n"
f"Stage: development\nNote: Staging image freshness failed "
f"(attempt {retry_count + 1}/{MAX_DEVELOPER_RETRIES}). "
f"Причина: {reason}."
)
new_job = enqueue_job("developer", repo, task_desc, task_id=task_id)
result.enqueued_agent = "developer"
result.enqueued_job_id = new_job
logger.info(
f"Task {task_id}: image-freshness FAILED, enqueued developer (job_id={new_job})"
)
else:
set_issue_blocked(work_item_id)
send_telegram(
f"\U0001f6a8 {work_item_id}: Staging image freshness still failing after "
f"{MAX_DEVELOPER_RETRIES} developer retries ({reason}). "
f"Manual intervention needed."
)
result.alerted = True
logger.error(
f"Task {task_id}: image-freshness FAILED, rolled back deploy-staging -> "
f"development ({reason})"
)
return True
# ---------------------------------------------------------------------------
# ORCH-036: executable self-deploy (Phase A/B/C)
# ---------------------------------------------------------------------------
def _handle_self_deploy_phase_a(
task_id, current_stage, repo, work_item_id, branch, result: AdvanceResult
):
"""Phase A — advance to `deploy` and request a manual approve (no prod deploy).
Staging is green and the branch is mergeable; for the self-hosting repo we do
NOT auto-deploy to prod. Move the task onto the `deploy` stage (so a later
human Approved lands there -> Phase B), set the issue approval-pending and ask
the human to flip the status to Approved. A restart-safe `approve-requested`
marker records that Phase A ran. The merge lease stays HELD.
"""
update_task_stage(task_id, "deploy")
notify_stage_change(task_id, current_stage, "deploy")
result.advanced = True
result.to_stage = "deploy"
result.note = "self-deploy-approval-pending"
if work_item_id:
set_issue_in_review(work_item_id)
# ORCH-036: belt-and-suspenders — wipe any STALE deploy-state markers before
# arming a fresh approve. A prior FAILED pass clears on rollback, but clearing
# here too guarantees the entry to every new prod-deploy pass starts clean
# (e.g. after a crash/manual intervention), so `initiated`/`result` from an
# earlier attempt can never leak into this one.
self_deploy.clear_state(repo, work_item_id)
self_deploy.write_marker(
repo, work_item_id, self_deploy.APPROVE_REQUESTED, content=str(time.time())
)
if work_item_id:
plane_add_comment(
work_item_id,
"\U0001f7e1 Staging зелёный. Требуется ручной approve для ПРОД-деплоя: "
"смените статус задачи на «Approved», чтобы запустить деплой в прод (8500).",
author="deployer",
)
send_telegram(
f"\U0001f7e1 {work_item_id}: staging OK. Ждёт approve на ПРОД-деплой "
f"(смените статус на Approved)."
)
logger.info(
f"Task {task_id}: self-deploy Phase A — advanced to deploy, "
f"approval-pending (awaiting human Approved)"
)
def _handle_self_deploy_phase_b(task_id, repo, work_item_id, branch, result: AdvanceResult):
"""Phase B — a human Approved initiates the DETACHED prod deploy (idempotent).
Idempotency-guard: if the `initiated` marker already exists (double Approved /
duplicate webhook, R-4) this is a no-op. Otherwise launch the detached host
deploy, and ONLY on success record `initiated` + enqueue the finalizer (so a
failed launch can be retried by re-approving). Returns without advancing — the
finalizer (Phase C) records the verdict once the hook finishes.
"""
if self_deploy.has_marker(repo, work_item_id, self_deploy.INITIATED):
result.note = "self-deploy-already-initiated"
logger.info(
f"Task {task_id}: prod deploy already initiated; ignoring repeat Approved"
)
return
ok, msg = self_deploy.initiate_deploy(repo, work_item_id, branch)
if not ok:
result.note = f"self-deploy-initiate-failed: {msg}"
if work_item_id:
plane_add_comment(
work_item_id,
f"⚠️ Не удалось запустить прод-деплой: {msg}. "
"Повторите approve после устранения причины.",
author="deployer",
)
send_telegram(f"⚠️ {work_item_id}: прод-деплой не запустился: {msg}")
logger.error(f"Task {task_id}: self-deploy initiate failed: {msg}")
return
self_deploy.write_marker(
repo, work_item_id, self_deploy.INITIATED, content=str(time.time())
)
task_desc = (
f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n"
f"Stage: deploy\nNote: deploy-finalize poll (prod self-deploy initiated)."
)
new_job = enqueue_job(
"deploy-finalizer", repo, task_desc, task_id=task_id,
available_at_delay_s=settings.deploy_finalize_delay_s,
)
result.enqueued_agent = "deploy-finalizer"
result.enqueued_job_id = new_job
result.note = "self-deploy-initiated"
if work_item_id:
plane_add_comment(
work_item_id,
"\U0001f680 Прод-деплой стартовал (detached host-процесс). "
"Вердикт будет зафиксирован после health-check.",
author="deployer",
)
send_telegram(f"\U0001f680 {work_item_id}: прод-деплой стартовал. Жду результат.")
logger.info(
f"Task {task_id}: self-deploy Phase B — detached deploy initiated, "
f"finalizer enqueued (job_id={new_job})"
)
def _deploy_finalize_defer_count(task_id: int) -> int:
"""How many times this task's finalizer has already deferred (restart-safe).
Counted from the persisted jobs queue by the defer marker in task_content
(mirrors _merge_defer_count), so a service restart never resets the budget.
"""
conn = get_db()
n = conn.execute(
"SELECT COUNT(*) FROM jobs WHERE task_id=? AND task_content LIKE '%deploy-finalize defer%'",
(task_id,),
).fetchone()[0]
conn.close()
return n
def run_deploy_finalizer(job: dict):
"""Phase C — deterministic finalizer (reserved-agent `deploy-finalizer`, no LLM).
Claimed by the worker in the NEW container after the prod restart. Reads the
`result` sentinel (hook exit-code written by the host wrapper):
* not written yet & budget left -> DEFER (re-queue with a delay);
* budget exhausted -> set_issue_blocked + Telegram (anti-livelock);
* present -> map exit-code -> deploy_status, write
14-deploy-log.md, then advance_stage(finished_agent="deployer") so the
EXISTING contracts fire: SUCCESS -> terminal-sync deploy->done + release
lease; FAILED -> БАГ-8 rollback deploy->development + set_issue_blocked.
Never raises into the caller (the launcher marks the job done/failed).
"""
task_id = job.get("task_id")
repo = job.get("repo")
conn = get_db()
row = conn.execute(
"SELECT work_item_id, branch FROM tasks WHERE id=?", (task_id,)
).fetchone()
conn.close()
if not row:
logger.error(f"deploy-finalizer: no task row for task_id={task_id}")
return
work_item_id, branch = row[0], row[1]
present, code = self_deploy.read_result(repo, work_item_id)
if not present:
defers = _deploy_finalize_defer_count(task_id)
if defers < settings.deploy_finalize_max_attempts:
task_desc = (
f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n"
f"Stage: deploy\nNote: deploy-finalize defer "
f"(attempt {defers + 1}/{settings.deploy_finalize_max_attempts}) — "
f"deploy result not ready, retrying after {settings.deploy_finalize_delay_s}s."
)
new_job = enqueue_job(
"deploy-finalizer", repo, task_desc, task_id=task_id,
available_at_delay_s=settings.deploy_finalize_delay_s,
)
logger.info(
f"Task {task_id}: deploy result not ready, finalizer deferred "
f"(job_id={new_job}, attempt {defers + 1}/{settings.deploy_finalize_max_attempts})"
)
else:
if work_item_id:
set_issue_blocked(work_item_id)
send_telegram(
f"\U0001f6a8 {work_item_id}: deploy result не появился после "
f"{settings.deploy_finalize_max_attempts} попыток. Нужно ручное вмешательство."
)
logger.error(
f"Task {task_id}: deploy-finalize defer attempts exhausted "
f"({settings.deploy_finalize_max_attempts})"
)
return
# Result present -> deterministic verdict.
status = self_deploy.map_exit_code_to_status(code)
self_deploy.write_deploy_log(repo, work_item_id, branch, code, status)
logger.info(
f"Task {task_id}: deploy finalized, hook exit={code} -> deploy_status={status}"
)
if status == "SUCCESS" and work_item_id:
plane_add_comment(
work_item_id,
f"✅ Прод-деплой успешен (health-check OK, exit {code}).",
author="deployer",
)
send_telegram(f"{work_item_id}: прод-деплой успешен (exit {code}).")
# Drive the EXISTING deploy contracts via the gate verdict we just wrote.
advance_stage(
task_id=task_id,
current_stage="deploy",
repo=repo,
work_item_id=work_item_id,
branch=branch,
finished_agent="deployer",
)

View File

@@ -115,3 +115,30 @@ def test_reconcile_settings_env_override(monkeypatch):
assert s.reconcile_grace_default_s == 900
assert s.reconcile_grace_overrides_json == '{"development": 300}'
assert s.reconcile_notify_unblock is False
# ---------------------------------------------------------------------------
# ORCH-058 / TC-13: image-freshness settings defaults + env override.
# ---------------------------------------------------------------------------
_FRESH_ENV = (
"ORCH_IMAGE_FRESHNESS_ENABLED",
"ORCH_IMAGE_FRESHNESS_REPOS",
)
def test_image_freshness_settings_defaults(monkeypatch):
"""TC-13 / AC-9: kill-switch ON by default, empty CSV (self-hosting only)."""
for name in _FRESH_ENV:
monkeypatch.delenv(name, raising=False)
s = Settings()
assert s.image_freshness_enabled is True
assert s.image_freshness_repos == ""
def test_image_freshness_settings_env_override(monkeypatch):
"""TC-13 / AC-9: each field is read from its ORCH_* env var."""
monkeypatch.setenv("ORCH_IMAGE_FRESHNESS_ENABLED", "false")
monkeypatch.setenv("ORCH_IMAGE_FRESHNESS_REPOS", "orchestrator,enduro-trails")
s = Settings()
assert s.image_freshness_enabled is False
assert s.image_freshness_repos == "orchestrator,enduro-trails"

View File

@@ -0,0 +1,161 @@
"""ORCH-036 TC-04/05/06: the manual-approve gate for the executable self-deploy.
Contract (AC-5, AC-12):
* TC-04 — ``deploy_require_manual_approve`` defaults to True in settings.
* TC-05 — flag true + NO human approve -> the prod hook is NEVER called; the
deploy-staging -> deploy edge only advances the STAGE and requests an approve
(Phase A). ``initiate_deploy`` / ssh subprocess must not be touched.
* TC-06 — flag true + a human Approved -> the prod hook is launched EXACTLY once
(Phase B), idempotent on a repeated Approved (the ``initiated`` marker guards).
"""
import os
import tempfile
import pytest
_test_db = os.path.join(tempfile.gettempdir(), "test_orch_deploy_approve.db")
os.environ["ORCH_DB_PATH"] = _test_db
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
from unittest.mock import MagicMock # noqa: E402
import src.db as _db # noqa: E402
from src.db import init_db, get_db # noqa: E402
from src import stage_engine # noqa: E402
from src import self_deploy # noqa: E402
from src.stage_engine import advance_stage # noqa: E402
@pytest.fixture(autouse=True)
def fresh_db(monkeypatch, tmp_path):
monkeypatch.setattr(_db.settings, "db_path", _test_db)
if os.path.exists(_test_db):
os.unlink(_test_db)
init_db()
# Isolate the sentinel state dirs to a per-test tmp dir.
monkeypatch.setattr(self_deploy.settings, "repos_dir", str(tmp_path))
monkeypatch.setattr(self_deploy.settings, "host_repos_dir", str(tmp_path))
yield
@pytest.fixture(autouse=True)
def silence_side_effects(monkeypatch):
for name in (
"notify_stage_change", "notify_qg_failure", "notify_approve_requested",
"send_telegram", "plane_notify_stage", "plane_notify_qg", "plane_add_comment",
"set_issue_in_review", "set_issue_needs_input", "set_issue_in_progress",
"set_issue_blocked", "set_issue_done",
):
monkeypatch.setattr(stage_engine, name, MagicMock())
def _make_task(stage, repo="orchestrator", branch="feature/ORCH-036-x", wi="ORCH-036"):
conn = get_db()
cur = conn.execute(
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
"VALUES (?, ?, ?, ?, ?)",
(f"plane-{wi}", wi, repo, branch, stage),
)
task_id = cur.lastrowid
conn.commit()
conn.close()
return task_id
def _stage(task_id):
conn = get_db()
row = conn.execute("SELECT stage FROM tasks WHERE id=?", (task_id,)).fetchone()
conn.close()
return row[0]
def _jobs():
conn = get_db()
rows = conn.execute("SELECT agent, repo, task_id FROM jobs ORDER BY id").fetchall()
conn.close()
return [dict(r) for r in rows]
def _pass(*a, **k):
return (True, "ok")
# ---------------------------------------------------------------------------
# TC-04: default flag value
# ---------------------------------------------------------------------------
def test_tc04_manual_approve_default_true():
"""The fresh, un-overridden settings default must be True (safe-by-default)."""
from src.config import Settings
assert Settings().deploy_require_manual_approve is True
# ---------------------------------------------------------------------------
# TC-05: flag true, no approve -> prod hook NOT called (Phase A only)
# ---------------------------------------------------------------------------
def test_tc05_no_approve_does_not_call_prod_hook(monkeypatch):
monkeypatch.setattr(stage_engine.settings, "deploy_require_manual_approve", True)
monkeypatch.setattr(
stage_engine, "QG_CHECKS",
{**stage_engine.QG_CHECKS,
"check_staging_status": _pass,
"check_branch_mergeable": _pass,
"check_staging_image_fresh": _pass},
)
# Spy: the deploy launcher must never run on the staging->deploy edge.
initiate = MagicMock()
monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", initiate)
ssh_run = MagicMock()
monkeypatch.setattr(self_deploy.subprocess, "run", ssh_run)
task_id = _make_task("deploy-staging")
res = advance_stage(
task_id, "deploy-staging", "orchestrator", "ORCH-036",
"feature/ORCH-036-x", finished_agent="deployer",
)
# Phase A: advanced the STAGE to deploy, but requested approve — no prod hook.
assert res.advanced is True
assert res.to_stage == "deploy"
assert _stage(task_id) == "deploy"
assert res.note == "self-deploy-approval-pending"
initiate.assert_not_called()
ssh_run.assert_not_called()
# No deployer job: the human Approved (Phase B) is what triggers the deploy.
assert _jobs() == []
# The restart-safe approve-requested marker was written.
assert self_deploy.has_marker("orchestrator", "ORCH-036", self_deploy.APPROVE_REQUESTED)
# ---------------------------------------------------------------------------
# TC-06: flag true + Approved -> prod hook called exactly once (idempotent)
# ---------------------------------------------------------------------------
def test_tc06_approved_calls_prod_hook_exactly_once(monkeypatch):
monkeypatch.setattr(stage_engine.settings, "deploy_require_manual_approve", True)
monkeypatch.setattr(stage_engine.settings, "deploy_ssh_host", "mva154")
# Real initiate_deploy, but the ssh subprocess is mocked (rc=0 -> dispatched).
ssh_run = MagicMock(return_value=MagicMock(returncode=0, stdout="", stderr=""))
monkeypatch.setattr(self_deploy.subprocess, "run", ssh_run)
task_id = _make_task("deploy") # already on deploy, awaiting Approved
# 1st human Approved -> Phase B initiates the detached deploy.
res1 = advance_stage(
task_id, "deploy", "orchestrator", "ORCH-036",
"feature/ORCH-036-x", finished_agent=None,
)
assert res1.note == "self-deploy-initiated"
assert ssh_run.call_count == 1
# The finalizer was enqueued.
assert any(j["agent"] == "deploy-finalizer" for j in _jobs())
assert self_deploy.has_marker("orchestrator", "ORCH-036", self_deploy.INITIATED)
# 2nd (duplicate) Approved -> idempotent no-op, hook NOT called again.
res2 = advance_stage(
task_id, "deploy", "orchestrator", "ORCH-036",
"feature/ORCH-036-x", finished_agent=None,
)
assert res2.note == "self-deploy-already-initiated"
assert ssh_run.call_count == 1 # still exactly one prod deploy

View File

@@ -0,0 +1,94 @@
"""ORCH-036 TC-14: prod deploy is build-ONCE — retag the staging image, no rebuild (AC-7).
The detached prod-deploy command must pass ``SOURCE_IMAGE=<staging-image>`` to the
hook so it retags the staging-validated image onto the prod tag instead of running
``docker build``. We assert the composed ssh command carries the staging source
image and never asks the hook to build.
"""
import os
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
from src import self_deploy # noqa: E402
def test_tc14_deploy_command_retags_staging_image_no_build(monkeypatch):
monkeypatch.setattr(self_deploy.settings, "deploy_ssh_user", "slin")
monkeypatch.setattr(self_deploy.settings, "deploy_ssh_host", "mva154")
monkeypatch.setattr(
self_deploy.settings, "deploy_prod_source_image", "orchestrator-orchestrator-staging"
)
cmd = self_deploy.build_deploy_command("orchestrator", "ORCH-036", "feature/ORCH-036-x")
remote = cmd[-1]
# The prevalidated staging image is handed to the hook as SOURCE_IMAGE (build-once).
assert "SOURCE_IMAGE=orchestrator-orchestrator-staging" in remote
# No rebuild is requested in the remote command.
assert "docker build" not in remote
assert "--build" not in remote
def test_tc14_hook_retag_branch_present():
"""The hook itself must honour SOURCE_IMAGE by retagging (no rebuild)."""
import pathlib
hook = pathlib.Path(__file__).resolve().parents[1] / "scripts" / "orchestrator-deploy-hook.sh"
text = hook.read_text(encoding="utf-8")
assert 'SOURCE_IMAGE="${SOURCE_IMAGE:-}"' in text
# Build-once retag branch present; the hook never runs `docker build`.
assert 'docker tag "$SOURCE_IMAGE" "$TARGET_IMAGE"' in text
# No EXECUTABLE `docker build` line on the PROD path (comments are fine).
# ORCH-058: the only build allowed is the staging-freshness rebuild,
# which is explicitly tagged with `--build-arg GIT_SHA` (Strategy A).
# Executable lines only: drop comments and `log "..."` strings that merely
# mention "docker build" in human-readable diagnostics.
exec_lines = [
ln.strip() for ln in text.splitlines()
if ln.strip()
and not ln.strip().startswith("#")
and not ln.strip().startswith("log ")
]
for ln in exec_lines:
if "docker build" in ln:
assert "--build-arg GIT_SHA" in ln, (
f"unexpected docker build on prod retag path: {ln}"
)
# ---------------------------------------------------------------------------
# ORCH-058 TC-06: build_deploy_command threads EXPECTED_REVISION (Strategy B)
# ---------------------------------------------------------------------------
def test_tc06_deploy_command_passes_expected_revision(monkeypatch):
"""When image-freshness is active, the prod hook receives EXPECTED_REVISION."""
from src import image_freshness
monkeypatch.setattr(self_deploy.settings, "deploy_ssh_user", "slin")
monkeypatch.setattr(self_deploy.settings, "deploy_ssh_host", "mva154")
monkeypatch.setattr(
self_deploy.settings, "deploy_prod_source_image", "orchestrator-orchestrator-staging"
)
monkeypatch.setattr(
image_freshness, "expected_revision", lambda repo, branch: "abc123def456"
)
cmd = self_deploy.build_deploy_command("orchestrator", "ORCH-058", "feature/ORCH-058-x")
remote = cmd[-1]
assert "EXPECTED_REVISION=abc123def456" in remote
def test_tc06_no_expected_revision_when_inactive(monkeypatch):
"""When image-freshness resolves to no SHA, EXPECTED_REVISION is omitted."""
from src import image_freshness
monkeypatch.setattr(self_deploy.settings, "deploy_ssh_user", "slin")
monkeypatch.setattr(self_deploy.settings, "deploy_ssh_host", "mva154")
monkeypatch.setattr(
self_deploy.settings, "deploy_prod_source_image", "orchestrator-orchestrator-staging"
)
monkeypatch.setattr(image_freshness, "expected_revision", lambda repo, branch: "")
cmd = self_deploy.build_deploy_command("orchestrator", "ORCH-058", "feature/ORCH-058-x")
remote = cmd[-1]
assert "EXPECTED_REVISION=" not in remote

View File

@@ -0,0 +1,72 @@
"""ORCH-036 TC-01/02/03: deterministic exit-code -> deploy_status mapping.
The finalizer (Phase C) maps the host-hook exit-code to the machine verdict via a
PURE function (no LLM, no I/O), so it is unit-testable in isolation. Contract
(hook exit-code 0/1/2, AC-1/AC-3): 0 -> SUCCESS; 1 (rolled back), 2 (rollback also
failed), and anything else -> FAILED (fail-closed).
"""
import os
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
from src import self_deploy # noqa: E402
from src.self_deploy import map_exit_code_to_status, build_deploy_log # noqa: E402
def test_tc01_exit0_maps_to_success():
assert map_exit_code_to_status(0) == "SUCCESS"
def test_tc02_exit1_rolled_back_maps_to_failed():
assert map_exit_code_to_status(1) == "FAILED"
def test_tc03_exit2_rollback_also_failed_maps_to_failed():
assert map_exit_code_to_status(2) == "FAILED"
def test_tc09_provenance_fail_closed_exit1_maps_to_failed():
"""ORCH-058 TC-09: the Strategy-B hook fail-close uses `exit 1`; that must map
to FAILED so the existing БАГ-8 rollback path triggers (prod never left stale)."""
assert map_exit_code_to_status(1) == "FAILED"
def test_other_exit_codes_map_to_failed():
for code in (3, 127, 255, -1):
assert map_exit_code_to_status(code) == "FAILED"
def test_non_int_or_none_maps_to_failed_fail_closed():
assert map_exit_code_to_status(None) == "FAILED"
assert map_exit_code_to_status("garbage") == "FAILED"
def test_deploy_log_frontmatter_carries_status():
"""The rendered log must expose deploy_status in YAML frontmatter so the
existing _parse_deploy_status contract (AC-10) reads the right verdict."""
body_ok = build_deploy_log("ORCH-036", 0, "SUCCESS")
assert body_ok.startswith("---\n")
assert "deploy_status: SUCCESS" in body_ok
body_fail = build_deploy_log("ORCH-036", 2, "FAILED")
assert "deploy_status: FAILED" in body_fail
assert "hook_exit_code: 2" in body_fail
def test_clear_state_removes_all_markers_and_is_idempotent(monkeypatch, tmp_path):
"""clear_state wipes the whole work-item state dir (all sentinels) and treats a
missing dir as success, so a re-deploy after rollback starts from a clean slate."""
monkeypatch.setattr(self_deploy.settings, "repos_dir", str(tmp_path))
repo, wi = "orchestrator", "ORCH-036"
self_deploy.write_marker(repo, wi, self_deploy.APPROVE_REQUESTED, "t")
self_deploy.write_marker(repo, wi, self_deploy.INITIATED, "t")
self_deploy.write_marker(repo, wi, self_deploy.RESULT, "1")
assert self_deploy.has_marker(repo, wi, self_deploy.INITIATED) is True
assert self_deploy.clear_state(repo, wi) is True
assert self_deploy.has_marker(repo, wi, self_deploy.APPROVE_REQUESTED) is False
assert self_deploy.has_marker(repo, wi, self_deploy.INITIATED) is False
assert self_deploy.has_marker(repo, wi, self_deploy.RESULT) is False
# Idempotent: clearing an already-absent dir is still success (never raises).
assert self_deploy.clear_state(repo, wi) is True

View File

@@ -0,0 +1,159 @@
"""ORCH-058 TC-07/08: static + caller-contract guarantees of the provenance plumbing.
These assert the *shape* of the deploy artefacts that can't be unit-tested by
running them (they shell out to docker/ssh on the host):
* TC-07 — the deploy hook fail-closes BEFORE `docker tag` when the staging
image's git-revision label != EXPECTED_REVISION (exit 1), and the
new `--build-staging` rebuild mode (a) stamps GIT_SHA into the image,
(b) uses $BUILD_CONTEXT as the build context, (c) recreates 8501 +
health-checks, (d) runs staging_check against the FRESH image
(Strategy A step 3, AC-4), and (e) never recomputes GIT_SHA from $REPO.
* TC-08 — the Dockerfile declares `ARG GIT_SHA` and stamps it into the
`org.opencontainers.image.revision` OCI label (the anchor B reads).
* TC-09 — the caller↔hook contract: `rebuild_staging_image` invokes the hook
in `--build-staging` mode with BUILD_CONTEXT=<host-worktree>,
GIT_SHA=<validated sha>, and an EXPLICIT staging target (never prod).
"""
import pathlib
_ROOT = pathlib.Path(__file__).resolve().parents[1]
_HOOK = _ROOT / "scripts" / "orchestrator-deploy-hook.sh"
_DOCKERFILE = _ROOT / "Dockerfile"
# ---------------------------------------------------------------------------
# TC-07: hook fail-closed provenance guard + --build-staging rebuild mode
# ---------------------------------------------------------------------------
def test_tc07_hook_has_fail_closed_provenance_guard():
text = _HOOK.read_text(encoding="utf-8")
# The label key the hook inspects must be the OCI revision label.
assert 'REVISION_LABEL="org.opencontainers.image.revision"' in text
# EXPECTED_REVISION is read (default unset -> backward compatible).
assert 'EXPECTED_REVISION="${EXPECTED_REVISION:-}"' in text
# The guard must inspect the source image's label and normalise <no value>.
assert "docker image inspect --format" in text
assert '"<no value>"' in text
# Fail-closed: empty OR mismatch -> abort with exit 1.
assert '-z "$IMG_REV" || "$IMG_REV" != "$EXPECTED_REVISION"' in text
def test_tc07_provenance_guard_precedes_docker_tag():
"""The fail-closed `exit 1` must sit BEFORE the `docker tag` retag line."""
text = _HOOK.read_text(encoding="utf-8")
guard = text.index("$EXPECTED_REVISION")
retag = text.index('docker tag "$SOURCE_IMAGE" "$TARGET_IMAGE"')
assert guard < retag, "provenance guard must run before the prod retag"
def test_tc07_build_staging_mode_stamps_git_sha():
text = _HOOK.read_text(encoding="utf-8")
# The new Strategy-A rebuild mode exists and is keyed on --build-staging.
assert '"${1:-}" == "--build-staging"' in text
# It rebuilds the staging image stamping the validated commit as a build-arg.
assert 'docker build --build-arg GIT_SHA="$GIT_SHA"' in text
def test_tc07_build_staging_uses_build_context_and_recreates_8501():
"""The rebuild must use $BUILD_CONTEXT as the docker build context and recreate
the staging service with a health-check (not a bare build)."""
text = _HOOK.read_text(encoding="utf-8")
# $BUILD_CONTEXT is the build context of the rebuild (validated worktree).
assert 'docker build --build-arg GIT_SHA="$GIT_SHA" -t "$TARGET_IMAGE" "$BUILD_CONTEXT"' in text
# Recreate the staging service on the fresh image (no-build) + health-check.
assert 'up -d --no-build "$TARGET_SERVICE"' in text
assert 'health_check 10 6 "build-staging-health"' in text
def test_tc07_build_staging_does_not_recompute_git_sha_from_repo():
"""Regression guard (root cause of the silent-stale-promote class): the
--build-staging mode must NOT derive GIT_SHA itself from the prod $REPO clone —
it must consume the GIT_SHA passed in by the caller (the validated commit)."""
text = _HOOK.read_text(encoding="utf-8")
# Anchor on the actual block guard (not the header comment mentions).
after = text[text.index('"${1:-}" == "--build-staging"'):]
assert 'GIT_SHA="${GIT_SHA:-}"' in after
assert "git rev-parse" not in after, "GIT_SHA must come from the caller, not the prod clone"
def test_tc07_build_staging_runs_staging_check_against_fresh_image():
"""Strategy A step 3 (ADR-001, AC-4): after recreate+health, the FRESH image is
validated by staging_check.py (not health-only). This is the P1 the reviewer
flagged: validate exactly the artefact later retagged to prod."""
text = _HOOK.read_text(encoding="utf-8")
# Anchor on the actual block guard (not the header comment mentions).
after = text[text.index('"${1:-}" == "--build-staging"'):]
# staging_check is invoked, inside the staging container, --mode stub by default.
assert "staging_check.py" in after
assert 'docker exec "$STAGING_CONTAINER"' in after
assert '--mode "$STAGING_CHECK_MODE"' in after
assert 'STAGING_CHECK_MODE="${STAGING_CHECK_MODE:-stub}"' in after
# The staging_check run must come AFTER the health-check (health gates readiness).
assert after.index('health_check 10 6 "build-staging-health"') < after.index("staging_check.py")
# ---------------------------------------------------------------------------
# TC-08: Dockerfile stamps the OCI revision label from a build-arg
# ---------------------------------------------------------------------------
def test_tc08_dockerfile_stamps_revision_label():
text = _DOCKERFILE.read_text(encoding="utf-8")
assert "ARG GIT_SHA" in text
assert "LABEL org.opencontainers.image.revision=$GIT_SHA" in text
# ---------------------------------------------------------------------------
# TC-09: caller↔hook contract — rebuild_staging_image builds the right command
# ---------------------------------------------------------------------------
def test_tc09_rebuild_staging_image_passes_validated_context_and_staging_target(monkeypatch):
"""`rebuild_staging_image` must invoke the hook `--build-staging` over ssh with
BUILD_CONTEXT=<host-worktree>, GIT_SHA=<validated sha>, and an EXPLICIT staging
target (service/port/profile/container) — never the prod 8500 target. The absence
of this contract test is what hid the earlier P0s (review P2)."""
import src.image_freshness as imgf
captured = {}
class _FakeCompleted:
returncode = 0
stdout = ""
stderr = ""
def _fake_run(cmd, *a, **kw):
captured["cmd"] = cmd
return _FakeCompleted()
monkeypatch.setattr(imgf, "_ssh_target", lambda: "slin@host")
monkeypatch.setattr(imgf, "_host_worktree_path",
lambda repo, branch: "/home/slin/repos/_wt/orchestrator/feature_X")
monkeypatch.setattr(imgf.subprocess, "run", _fake_run)
ok, msg = imgf.rebuild_staging_image("orchestrator", "feature/ORCH-058", "abc123def456")
assert ok, msg
cmd = captured["cmd"]
assert cmd[0] == "ssh"
inner = cmd[-1] # the remote shell command string
# Validated commit + validated worktree as build context.
assert "GIT_SHA=abc123def456" in inner
assert "BUILD_CONTEXT=/home/slin/repos/_wt/orchestrator/feature_X" in inner
# Explicit STAGING target — never the prod 8500 service/port.
assert "TARGET_SERVICE=orchestrator-staging" in inner
assert "TARGET_PORT=8501" in inner
assert "COMPOSE_PROFILE=staging" in inner
assert "STAGING_CONTAINER=orchestrator-staging" in inner
assert "orchestrator-orchestrator-staging" in inner # staging TARGET_IMAGE
assert "--build-staging" in inner
# Hard safety: the prod service/port must NOT leak into the staging rebuild.
assert "TARGET_PORT=8500" not in inner
assert "TARGET_SERVICE=orchestrator " not in inner
def test_tc09_rebuild_staging_image_no_ssh_host_fails_closed(monkeypatch):
"""No ssh host configured -> never-raise, fail-closed (False), no command run."""
import src.image_freshness as imgf
monkeypatch.setattr(imgf, "_ssh_target", lambda: None)
ok, reason = imgf.rebuild_staging_image("orchestrator", "feature/ORCH-058", "abc123")
assert ok is False
assert "ssh host" in reason

View File

@@ -0,0 +1,118 @@
"""ORCH-036 TC-19: deploy-hook auto-rollback simulation (AC-9).
Drives the REAL ``scripts/orchestrator-deploy-hook.sh`` in a hermetic sandbox:
``docker`` / ``curl`` / ``git`` / ``sleep`` are replaced by PATH-shimmed stubs so
no real infra is touched (and prod is never restarted — INFRA safety). The curl
stub is stateful: the freshly-deployed service is UNHEALTHY for the whole deploy
health-check window, which must trigger the hook's AUTO-ROLLBACK; after the
rollback restart the previous image is HEALTHY again.
Expected hook contract (exit-code 0/1/2):
* health fails -> auto rollback -> previous image healthy -> exit 1 (rolled back);
* the whole run completes well under the 60s MTTR budget (sleeps are shimmed).
"""
import os
import shutil
import stat
import subprocess
import time
import pytest
HOOK = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"scripts", "orchestrator-deploy-hook.sh",
)
pytestmark = pytest.mark.skipif(
shutil.which("bash") is None, reason="bash required for hook simulation"
)
def _write_exec(path, content):
with open(path, "w", encoding="utf-8") as f:
f.write(content)
os.chmod(path, os.stat(path).st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
def _setup_sandbox(tmp_path):
"""Create PATH-shimmed docker/curl/git/sleep stubs + a rewritten hook copy."""
binx = tmp_path / "bin"
binx.mkdir()
state = tmp_path / "state"
state.mkdir()
repo = tmp_path / "repo"
repo.mkdir()
cnt = state / "curl_count"
# docker: fake a running service + a recoverable previous image.
_write_exec(str(binx / "docker"), """#!/bin/bash
case "$1" in
compose)
for a in "$@"; do [ "$a" = "ps" ] && { echo "fakecid"; exit 0; }; done
exit 0;;
inspect) echo "sha256:previmage"; exit 0;;
image) exit 0;; # docker image inspect <img> -> found
tag) exit 0;;
*) exit 0;;
esac
""")
# curl: first 20 invocations (10 deploy health attempts x2 calls) UNHEALTHY,
# then HEALTHY (the rolled-back previous image).
_write_exec(str(binx / "curl"), f"""#!/bin/bash
CNT="{cnt}"
n=$(cat "$CNT" 2>/dev/null || echo 0); n=$((n+1)); echo "$n" > "$CNT"
iscode=""
for a in "$@"; do [ "$a" = "-w" ] && iscode=1; done
if [ "$n" -gt 20 ]; then
[ -n "$iscode" ] && echo "200" || echo '{{"status":"ok"}}'
else
[ -n "$iscode" ] && echo "000" || echo ""
fi
exit 0
""")
_write_exec(str(binx / "git"), "#!/bin/bash\nexit 0\n")
# Shim sleep to a no-op so the simulation runs fast (real timing is governed
# by the hook's sleep args; here we only assert the rollback CONTROL FLOW).
_write_exec(str(binx / "sleep"), "#!/bin/bash\nexit 0\n")
# Copy the hook, repointing REPO to the sandbox (avoids the hardcoded prod path).
hook_text = open(HOOK, encoding="utf-8").read()
hook_text = hook_text.replace(
"REPO=/home/slin/repos/orchestrator", f"REPO={repo}"
)
hook_copy = tmp_path / "hook.sh"
_write_exec(str(hook_copy), hook_text)
env = {
**os.environ,
"PATH": f"{binx}:{os.environ['PATH']}",
"LOG": str(state / "hook.log"),
"PREV_IMAGE_FILE": str(state / "prev-image"),
"COMPOSE_PROFILE": "staging",
"TARGET_SERVICE": "orchestrator-staging",
"TARGET_PORT": "8501",
}
return hook_copy, env
def test_tc19_unhealthy_deploy_auto_rolls_back_exit1(tmp_path):
hook_copy, env = _setup_sandbox(tmp_path)
t0 = time.time()
proc = subprocess.run(
["bash", str(hook_copy), "--deploy"],
env=env, capture_output=True, text=True, timeout=60,
)
elapsed = time.time() - t0
# AC-9: unhealthy deploy -> auto rollback succeeded on the previous image -> exit 1.
assert proc.returncode == 1, f"stdout={proc.stdout}\nstderr={proc.stderr}"
out = proc.stdout + proc.stderr
assert "AUTO ROLLBACK" in out
assert "rolled back to previous image successfully" in out
# MTTR well under the 60s budget (sleeps shimmed; control flow only).
assert elapsed < 60

View File

@@ -0,0 +1,102 @@
"""ORCH-036 TC-12/13: no silent deploy — both Plane AND Telegram are notified (AC-6).
The finalizer (Phase C) must announce the prod-deploy outcome on BOTH channels:
* TC-12 — a SUCCESS deploy -> a Plane comment AND a Telegram message.
* TC-13 — a FAILED deploy (rollback) -> a Plane comment AND a Telegram message.
"""
import os
import tempfile
import pytest
_test_db = os.path.join(tempfile.gettempdir(), "test_orch_deploy_notif.db")
os.environ["ORCH_DB_PATH"] = _test_db
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
from unittest.mock import MagicMock # noqa: E402
import src.db as _db # noqa: E402
from src.db import init_db, get_db # noqa: E402
from src import stage_engine # noqa: E402
from src import self_deploy # noqa: E402
@pytest.fixture(autouse=True)
def fresh_db(monkeypatch, tmp_path):
monkeypatch.setattr(_db.settings, "db_path", _test_db)
if os.path.exists(_test_db):
os.unlink(_test_db)
init_db()
monkeypatch.setattr(self_deploy.settings, "repos_dir", str(tmp_path))
monkeypatch.setattr(self_deploy.settings, "host_repos_dir", str(tmp_path))
monkeypatch.setattr(stage_engine.self_deploy, "write_deploy_log", MagicMock(return_value=True))
monkeypatch.setattr(stage_engine.merge_gate, "release_merge_lease", MagicMock())
yield
@pytest.fixture(autouse=True)
def silence_side_effects(monkeypatch):
for name in (
"notify_stage_change", "notify_qg_failure", "notify_approve_requested",
"send_telegram", "plane_notify_stage", "plane_notify_qg", "plane_add_comment",
"set_issue_in_review", "set_issue_needs_input", "set_issue_in_progress",
"set_issue_blocked", "set_issue_done",
):
monkeypatch.setattr(stage_engine, name, MagicMock())
def _make_task(stage, repo="orchestrator", branch="feature/ORCH-036-x", wi="ORCH-036"):
conn = get_db()
cur = conn.execute(
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
"VALUES (?, ?, ?, ?, ?)",
(f"plane-{wi}", wi, repo, branch, stage),
)
task_id = cur.lastrowid
conn.commit()
conn.close()
return task_id
def _pass(*a, **k):
return (True, "ok")
def _fail(reason):
def _f(*a, **k):
return (False, reason)
return _f
def _run_finalizer(task_id):
stage_engine.run_deploy_finalizer(
{"task_id": task_id, "repo": "orchestrator", "id": 1, "agent": "deploy-finalizer"}
)
def test_tc12_success_notifies_plane_and_telegram(monkeypatch):
self_deploy.write_marker("orchestrator", "ORCH-036", self_deploy.RESULT, "0")
monkeypatch.setattr(
stage_engine, "QG_CHECKS",
{**stage_engine.QG_CHECKS, "check_deploy_status": _pass},
)
task_id = _make_task("deploy")
_run_finalizer(task_id)
assert stage_engine.plane_add_comment.called
assert stage_engine.send_telegram.called
def test_tc13_rollback_notifies_plane_and_telegram(monkeypatch):
self_deploy.write_marker("orchestrator", "ORCH-036", self_deploy.RESULT, "1")
monkeypatch.setattr(
stage_engine, "QG_CHECKS",
{**stage_engine.QG_CHECKS, "check_deploy_status": _fail("Deploy status: FAILED")},
)
task_id = _make_task("deploy")
_run_finalizer(task_id)
# The БАГ-8 rollback path announces on both channels (no silent failure).
assert stage_engine.send_telegram.called
assert stage_engine.plane_add_comment.called or stage_engine.plane_notify_qg.called

View File

@@ -0,0 +1,141 @@
"""ORCH-036 TC-10: a FAILED prod deploy rolls back deploy -> development (AC-4).
The finalizer (Phase C) reads the hook ``result`` sentinel, maps a non-zero exit
to ``deploy_status: FAILED`` and then drives the EXISTING deploy contract via
``advance_stage(finished_agent="deployer")``. With a FAILED verdict the БАГ-8
rollback fires: deploy -> development, ``set_issue_blocked`` + Telegram alert, and
(for the self-hosting repo) the merge-lease is released so the branch is not
wedged. The hook exit-code -> verdict mapping is unit-tested in
``test_deploy_hook_mapping.py``; here we assert the engine REACTION.
"""
import os
import tempfile
import pytest
_test_db = os.path.join(tempfile.gettempdir(), "test_orch_deploy_rollback.db")
os.environ["ORCH_DB_PATH"] = _test_db
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
from unittest.mock import MagicMock # noqa: E402
import src.db as _db # noqa: E402
from src.db import init_db, get_db # noqa: E402
from src import stage_engine # noqa: E402
from src import self_deploy # noqa: E402
@pytest.fixture(autouse=True)
def fresh_db(monkeypatch, tmp_path):
monkeypatch.setattr(_db.settings, "db_path", _test_db)
if os.path.exists(_test_db):
os.unlink(_test_db)
init_db()
monkeypatch.setattr(self_deploy.settings, "repos_dir", str(tmp_path))
monkeypatch.setattr(self_deploy.settings, "host_repos_dir", str(tmp_path))
# The finalizer's deploy-log write touches a git worktree we don't have here;
# the verdict it drives comes from check_deploy_status (monkeypatched below).
monkeypatch.setattr(stage_engine.self_deploy, "write_deploy_log", MagicMock(return_value=True))
yield
@pytest.fixture(autouse=True)
def silence_side_effects(monkeypatch):
for name in (
"notify_stage_change", "notify_qg_failure", "notify_approve_requested",
"send_telegram", "plane_notify_stage", "plane_notify_qg", "plane_add_comment",
"set_issue_in_review", "set_issue_needs_input", "set_issue_in_progress",
"set_issue_blocked", "set_issue_done",
):
monkeypatch.setattr(stage_engine, name, MagicMock())
def _make_task(stage, repo="orchestrator", branch="feature/ORCH-036-x", wi="ORCH-036"):
conn = get_db()
cur = conn.execute(
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
"VALUES (?, ?, ?, ?, ?)",
(f"plane-{wi}", wi, repo, branch, stage),
)
task_id = cur.lastrowid
conn.commit()
conn.close()
return task_id
def _stage(task_id):
conn = get_db()
row = conn.execute("SELECT stage FROM tasks WHERE id=?", (task_id,)).fetchone()
conn.close()
return row[0]
def _fail(reason):
def _f(*a, **k):
return (False, reason)
return _f
def test_tc10_failed_deploy_rolls_back_to_development(monkeypatch):
# Hook reported exit 1 (rolled back) -> the host wrapper wrote result=1.
self_deploy.write_marker("orchestrator", "ORCH-036", self_deploy.RESULT, "1")
# The deploy-log verdict the gate reads is FAILED.
monkeypatch.setattr(
stage_engine, "QG_CHECKS",
{**stage_engine.QG_CHECKS, "check_deploy_status": _fail("Deploy status: FAILED")},
)
task_id = _make_task("deploy")
stage_engine.run_deploy_finalizer(
{"task_id": task_id, "repo": "orchestrator", "id": 1, "agent": "deploy-finalizer"}
)
# БАГ-8 rollback fired: NOT done, back on development, blocked + alerted.
assert _stage(task_id) == "development"
assert stage_engine.set_issue_blocked.called
assert stage_engine.send_telegram.called
assert stage_engine.set_issue_done.called is False
def test_tc11_re_deploy_after_rollback_not_wedged(monkeypatch):
"""FAILED deploy -> rollback wipes stale markers so a later Phase B re-initiates.
Regression for the re-deploy-after-rollback contract (AC-4/AC-10): markers are
keyed by the (stable) work_item_id, so without cleanup the STALE `initiated` from
the first failed attempt would make Phase B's idempotency-guard a no-op on the
retry and wedge the task on `deploy` forever.
"""
repo, wi, branch = "orchestrator", "ORCH-036", "feature/ORCH-036-x"
# First (failed) pass left BOTH the idempotency-guard and the verdict behind.
self_deploy.write_marker(repo, wi, self_deploy.INITIATED, "123")
self_deploy.write_marker(repo, wi, self_deploy.RESULT, "1")
monkeypatch.setattr(
stage_engine, "QG_CHECKS",
{**stage_engine.QG_CHECKS, "check_deploy_status": _fail("Deploy status: FAILED")},
)
task_id = _make_task("deploy")
stage_engine.run_deploy_finalizer(
{"task_id": task_id, "repo": repo, "id": 1, "agent": "deploy-finalizer"}
)
# Rollback fired AND the stale deploy-state sentinels were wiped.
assert _stage(task_id) == "development"
assert self_deploy.has_marker(repo, wi, self_deploy.INITIATED) is False
assert self_deploy.has_marker(repo, wi, self_deploy.RESULT) is False
assert self_deploy.read_result(repo, wi) == (False, None)
# Second pass: the task reaches `deploy` again and the human re-approves. Phase B
# must ACTUALLY initiate (no stale `initiated` -> not a no-op), proving the retry
# is no longer wedged.
init = MagicMock(return_value=(True, "ok"))
monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", init)
result = stage_engine.AdvanceResult(from_stage="deploy")
stage_engine._handle_self_deploy_phase_b(task_id, repo, wi, branch, result)
assert init.called
assert result.note == "self-deploy-initiated"
assert self_deploy.has_marker(repo, wi, self_deploy.INITIATED) is True

View File

@@ -0,0 +1,174 @@
"""ORCH-036 TC-07/08/09: self vs non-self deploy routing (AC-2, AC-11).
* TC-07 — ``is_self_hosting_repo``/``self_deploy_applies`` recognise the
orchestrator repo and reject any other (no regression).
* TC-08 — for the self repo the restart is launched as a DETACHED host process
(ssh + setsid + background), never synchronously inside the agent.
* TC-09 — for a non-self repo (enduro-trails) the deploy keeps the legacy path:
the self-deploy Phase A/B logic does NOT apply.
"""
import os
import tempfile
import pytest
_test_db = os.path.join(tempfile.gettempdir(), "test_orch_deploy_routing.db")
os.environ["ORCH_DB_PATH"] = _test_db
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
from unittest.mock import MagicMock # noqa: E402
import src.db as _db # noqa: E402
from src.db import init_db, get_db # noqa: E402
from src import stage_engine # noqa: E402
from src import self_deploy # noqa: E402
from src.qg.checks import is_self_hosting_repo # noqa: E402
from src.stage_engine import advance_stage # noqa: E402
@pytest.fixture(autouse=True)
def fresh_db(monkeypatch, tmp_path):
monkeypatch.setattr(_db.settings, "db_path", _test_db)
if os.path.exists(_test_db):
os.unlink(_test_db)
init_db()
monkeypatch.setattr(self_deploy.settings, "repos_dir", str(tmp_path))
monkeypatch.setattr(self_deploy.settings, "host_repos_dir", str(tmp_path))
yield
@pytest.fixture(autouse=True)
def silence_side_effects(monkeypatch):
for name in (
"notify_stage_change", "notify_qg_failure", "notify_approve_requested",
"send_telegram", "plane_notify_stage", "plane_notify_qg", "plane_add_comment",
"set_issue_in_review", "set_issue_needs_input", "set_issue_in_progress",
"set_issue_blocked", "set_issue_done",
):
monkeypatch.setattr(stage_engine, name, MagicMock())
def _make_task(stage, repo, branch, wi):
conn = get_db()
cur = conn.execute(
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
"VALUES (?, ?, ?, ?, ?)",
(f"plane-{wi}", wi, repo, branch, stage),
)
task_id = cur.lastrowid
conn.commit()
conn.close()
return task_id
def _stage(task_id):
conn = get_db()
row = conn.execute("SELECT stage FROM tasks WHERE id=?", (task_id,)).fetchone()
conn.close()
return row[0]
def _jobs():
conn = get_db()
rows = conn.execute("SELECT agent, repo, task_id FROM jobs ORDER BY id").fetchall()
conn.close()
return [dict(r) for r in rows]
def _pass(*a, **k):
return (True, "ok")
# ---------------------------------------------------------------------------
# TC-07: routing predicate
# ---------------------------------------------------------------------------
def test_tc07_is_self_hosting_repo_only_orchestrator():
assert is_self_hosting_repo("orchestrator") is True
assert is_self_hosting_repo("ORCHESTRATOR") is True # case-insensitive
assert is_self_hosting_repo("enduro-trails") is False
assert is_self_hosting_repo("") is False
assert is_self_hosting_repo(None) is False
def test_tc07_self_deploy_applies_mirrors_routing(monkeypatch):
monkeypatch.setattr(self_deploy.settings, "self_deploy_enabled", True)
monkeypatch.setattr(self_deploy.settings, "self_deploy_repos", "")
assert self_deploy.self_deploy_applies("orchestrator") is True
assert self_deploy.self_deploy_applies("enduro-trails") is False
# Global kill-switch wins.
monkeypatch.setattr(self_deploy.settings, "self_deploy_enabled", False)
assert self_deploy.self_deploy_applies("orchestrator") is False
# ---------------------------------------------------------------------------
# TC-08: self repo -> DETACHED host process (ssh + setsid + background)
# ---------------------------------------------------------------------------
def test_tc08_self_repo_launches_detached_host_process(monkeypatch):
"""The deploy command must be an ssh invocation that detaches the hook via
setsid and backgrounds it (`&`), so it survives the prod container restart —
i.e. NOT a synchronous in-agent call."""
monkeypatch.setattr(self_deploy.settings, "deploy_ssh_user", "slin")
monkeypatch.setattr(self_deploy.settings, "deploy_ssh_host", "mva154")
cmd = self_deploy.build_deploy_command("orchestrator", "ORCH-036", "feature/ORCH-036-x")
assert cmd[0] == "ssh"
assert "slin@mva154" in cmd
remote = cmd[-1]
assert "setsid" in remote # detached session
assert remote.rstrip().endswith("&") # backgrounded
assert "</dev/null" in remote # stdin detached
assert "--deploy" in remote # runs the deploy hook
def test_tc08_initiate_deploy_uses_subprocess_not_blocking(monkeypatch):
"""initiate_deploy dispatches via subprocess (the ssh call returns at once);
a rc=0 means 'detached process launched', not 'deploy finished'."""
captured = {}
def fake_run(cmd, **kwargs):
captured["cmd"] = cmd
return MagicMock(returncode=0, stdout="", stderr="")
monkeypatch.setattr(self_deploy.settings, "deploy_ssh_host", "mva154")
monkeypatch.setattr(self_deploy.subprocess, "run", fake_run)
ok, msg = self_deploy.initiate_deploy("orchestrator", "ORCH-036", "feature/ORCH-036-x")
assert ok is True
assert captured["cmd"][0] == "ssh"
assert "detached" in msg
# ---------------------------------------------------------------------------
# TC-09: non-self repo -> legacy path, self-deploy logic does not apply
# ---------------------------------------------------------------------------
def test_tc09_non_self_repo_uses_legacy_path(monkeypatch):
"""enduro-trails on the deploy-staging -> deploy edge: no Phase A interception,
the deployer is enqueued for the deploy stage exactly as before ORCH-036."""
monkeypatch.setattr(stage_engine.settings, "deploy_require_manual_approve", True)
monkeypatch.setattr(
stage_engine, "QG_CHECKS",
{**stage_engine.QG_CHECKS, "check_staging_status": _pass},
) # check_branch_mergeable left REAL -> N/A for non-self repo
# Spy: self-deploy must not be initiated for a non-self repo.
initiate = MagicMock()
monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", initiate)
task_id = _make_task("deploy-staging", "enduro-trails", "feature/ET-009-x", "ET-009")
res = advance_stage(
task_id, "deploy-staging", "enduro-trails", "ET-009",
"feature/ET-009-x", finished_agent="deployer",
)
assert res.advanced is True
assert _stage(task_id) == "deploy"
assert res.note != "self-deploy-approval-pending"
initiate.assert_not_called()
# Legacy path enqueues the deployer for the deploy stage.
jobs = _jobs()
assert len(jobs) == 1
assert jobs[0]["agent"] == "deployer"
# No self-deploy marker for the non-self repo.
assert not self_deploy.has_marker("enduro-trails", "ET-009", self_deploy.APPROVE_REQUESTED)

View File

@@ -0,0 +1,104 @@
"""ORCH-036 TC-17: a SUCCESS prod deploy preserves the terminal-sync contract (AC-10).
When the finalizer (Phase C) reads exit 0 -> ``deploy_status: SUCCESS`` and drives
``advance_stage(finished_agent="deployer")``, the EXISTING deploy->done transition
must still fire unchanged: stage becomes ``done``, ``set_issue_done`` is called, no
agent is launched, and the merge-lease is released (terminal-sync, ORCH-43/БАГ-8
contract). ORCH-036 only changes HOW the verdict is produced, never the contract.
"""
import os
import tempfile
import pytest
_test_db = os.path.join(tempfile.gettempdir(), "test_orch_deploy_terminal.db")
os.environ["ORCH_DB_PATH"] = _test_db
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
from unittest.mock import MagicMock # noqa: E402
import src.db as _db # noqa: E402
from src.db import init_db, get_db # noqa: E402
from src import stage_engine # noqa: E402
from src import self_deploy # noqa: E402
@pytest.fixture(autouse=True)
def fresh_db(monkeypatch, tmp_path):
monkeypatch.setattr(_db.settings, "db_path", _test_db)
if os.path.exists(_test_db):
os.unlink(_test_db)
init_db()
monkeypatch.setattr(self_deploy.settings, "repos_dir", str(tmp_path))
monkeypatch.setattr(self_deploy.settings, "host_repos_dir", str(tmp_path))
monkeypatch.setattr(stage_engine.self_deploy, "write_deploy_log", MagicMock(return_value=True))
yield
@pytest.fixture(autouse=True)
def silence_side_effects(monkeypatch):
for name in (
"notify_stage_change", "notify_qg_failure", "notify_approve_requested",
"send_telegram", "plane_notify_stage", "plane_notify_qg", "plane_add_comment",
"set_issue_in_review", "set_issue_needs_input", "set_issue_in_progress",
"set_issue_blocked", "set_issue_done",
):
monkeypatch.setattr(stage_engine, name, MagicMock())
def _make_task(stage, repo="orchestrator", branch="feature/ORCH-036-x", wi="ORCH-036"):
conn = get_db()
cur = conn.execute(
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
"VALUES (?, ?, ?, ?, ?)",
(f"plane-{wi}", wi, repo, branch, stage),
)
task_id = cur.lastrowid
conn.commit()
conn.close()
return task_id
def _stage(task_id):
conn = get_db()
row = conn.execute("SELECT stage FROM tasks WHERE id=?", (task_id,)).fetchone()
conn.close()
return row[0]
def _jobs():
conn = get_db()
rows = conn.execute("SELECT agent FROM jobs ORDER BY id").fetchall()
conn.close()
return [r[0] for r in rows]
def _pass(*a, **k):
return (True, "ok")
def test_tc17_success_deploy_syncs_terminal_done(monkeypatch):
# Hook reported exit 0 -> the host wrapper wrote result=0.
self_deploy.write_marker("orchestrator", "ORCH-036", self_deploy.RESULT, "0")
monkeypatch.setattr(
stage_engine, "QG_CHECKS",
{**stage_engine.QG_CHECKS, "check_deploy_status": _pass},
)
# Spy the merge-lease release to confirm the terminal-sync still frees it.
release = MagicMock()
monkeypatch.setattr(stage_engine.merge_gate, "release_merge_lease", release)
task_id = _make_task("deploy")
stage_engine.run_deploy_finalizer(
{"task_id": task_id, "repo": "orchestrator", "id": 1, "agent": "deploy-finalizer"}
)
assert _stage(task_id) == "done"
assert stage_engine.set_issue_done.called
# The merge-lease is released on the deploy->done terminal-sync.
release.assert_called_once_with("orchestrator", "feature/ORCH-036-x")
# No agent is launched leaving deploy (terminal).
assert _jobs() == []

View File

@@ -0,0 +1,171 @@
"""ORCH-058 TC-01..05: staging-image provenance helpers (src/image_freshness.py).
Covers the INV-FRESH building blocks in isolation:
* TC-01/02/03 — the PURE provenance verdict (match / mismatch / fail-closed).
* TC-04 — never-raise: docker/ssh/git errors -> safe verdict, no exception.
* TC-05 — conditionality: non-self repo = no-op (N/A); self repo = real.
"""
import os
import subprocess
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
from src import image_freshness as imf # noqa: E402
# ---------------------------------------------------------------------------
# TC-01: matching revisions -> fresh (PASS)
# ---------------------------------------------------------------------------
def test_tc01_matching_revisions_are_fresh():
ok, reason = imf.provenance_verdict("abc123def456", "abc123def456")
assert ok is True
assert "match" in reason.lower()
# ---------------------------------------------------------------------------
# TC-02: differing revisions -> NOT fresh (input for fail-fast)
# ---------------------------------------------------------------------------
def test_tc02_differing_revisions_are_not_fresh():
ok, reason = imf.provenance_verdict("aaaaaaaaaaaa", "bbbbbbbbbbbb")
assert ok is False
assert "mismatch" in reason.lower()
# ---------------------------------------------------------------------------
# TC-03: fail-closed — empty label OR empty expected -> never "fresh by default"
# ---------------------------------------------------------------------------
def test_tc03_empty_image_label_fails_closed():
ok, reason = imf.provenance_verdict("abc123", "")
assert ok is False
assert "fail-closed" in reason.lower()
def test_tc03_empty_expected_revision_fails_closed():
ok, reason = imf.provenance_verdict("", "abc123")
assert ok is False
assert "fail-closed" in reason.lower()
def test_tc03_both_empty_fails_closed():
ok, _ = imf.provenance_verdict("", "")
assert ok is False
# ---------------------------------------------------------------------------
# TC-04: never-raise on docker/ssh/inspect/git errors -> safe verdict
# ---------------------------------------------------------------------------
def test_tc04_image_revision_inspect_error_returns_empty(monkeypatch):
def _boom(*a, **k):
raise OSError("docker not found")
monkeypatch.setattr(imf.subprocess, "run", _boom)
# Never raises; fail-closed empty -> downstream provenance mismatch.
assert imf.image_revision("orchestrator-orchestrator-staging") == ""
def test_tc04_image_revision_nonzero_rc_returns_empty(monkeypatch):
monkeypatch.setattr(
imf.subprocess, "run",
lambda *a, **k: subprocess.CompletedProcess(a, 1, stdout="", stderr="no such image"),
)
assert imf.image_revision("missing-image") == ""
def test_tc04_image_revision_no_value_label_returns_empty(monkeypatch):
# `docker inspect` prints "<no value>" when the label key is absent.
monkeypatch.setattr(
imf.subprocess, "run",
lambda *a, **k: subprocess.CompletedProcess(a, 0, stdout="<no value>\n", stderr=""),
)
assert imf.image_revision("unlabelled-image") == ""
def test_tc04_validated_revision_missing_worktree_returns_empty(monkeypatch, tmp_path):
# No worktree on disk -> fail-closed empty SHA, never raises.
monkeypatch.setattr(imf.settings, "worktrees_dir", str(tmp_path / "nope"))
monkeypatch.setattr(imf.settings, "repos_dir", str(tmp_path / "nope"))
assert imf.validated_revision("orchestrator", "feature/ORCH-058-x") == ""
def test_tc04_check_staging_image_fresh_never_raises(monkeypatch):
# Self repo + enabled, but rebuild blows up -> caught -> safe (False) verdict.
monkeypatch.setattr(imf.settings, "image_freshness_enabled", True)
monkeypatch.setattr(imf.settings, "image_freshness_repos", "")
monkeypatch.setattr(imf, "validated_revision", lambda r, b: "deadbeef")
def _boom(*a, **k):
raise RuntimeError("ssh exploded")
monkeypatch.setattr(imf, "rebuild_staging_image", _boom)
ok, reason = imf.check_staging_image_fresh("orchestrator", "ORCH-058", "feature/ORCH-058-x")
assert ok is False
assert "error" in reason.lower()
# ---------------------------------------------------------------------------
# TC-05: conditionality (self-hosting only)
# ---------------------------------------------------------------------------
def test_tc05_applies_only_to_self_hosting_by_default(monkeypatch):
monkeypatch.setattr(imf.settings, "image_freshness_enabled", True)
monkeypatch.setattr(imf.settings, "image_freshness_repos", "")
assert imf.image_freshness_applies("orchestrator") is True
assert imf.image_freshness_applies("enduro-trails") is False
def test_tc05_applies_respects_repos_csv(monkeypatch):
monkeypatch.setattr(imf.settings, "image_freshness_enabled", True)
monkeypatch.setattr(imf.settings, "image_freshness_repos", "enduro-trails")
assert imf.image_freshness_applies("enduro-trails") is True
# CSV is authoritative: orchestrator not listed -> not real.
assert imf.image_freshness_applies("orchestrator") is False
def test_tc05_kill_switch_disables_for_everyone(monkeypatch):
monkeypatch.setattr(imf.settings, "image_freshness_enabled", False)
monkeypatch.setattr(imf.settings, "image_freshness_repos", "")
assert imf.image_freshness_applies("orchestrator") is False
def test_tc05_check_is_noop_for_non_self_repo(monkeypatch):
monkeypatch.setattr(imf.settings, "image_freshness_enabled", True)
monkeypatch.setattr(imf.settings, "image_freshness_repos", "")
ok, reason = imf.check_staging_image_fresh("enduro-trails", "ET-001", "feature/ET-001-x")
assert ok is True
assert "N/A" in reason
def test_tc05_check_disabled_is_pass(monkeypatch):
monkeypatch.setattr(imf.settings, "image_freshness_enabled", False)
ok, reason = imf.check_staging_image_fresh("orchestrator", "ORCH-058", "feature/ORCH-058-x")
assert ok is True
assert "disabled" in reason.lower()
def test_tc05_check_real_for_self_repo_rebuilds(monkeypatch):
# Self repo + enabled: validated commit resolved + rebuild OK -> fresh PASS.
monkeypatch.setattr(imf.settings, "image_freshness_enabled", True)
monkeypatch.setattr(imf.settings, "image_freshness_repos", "")
monkeypatch.setattr(imf, "validated_revision", lambda r, b: "abc123def456")
monkeypatch.setattr(imf, "rebuild_staging_image", lambda r, b, s: (True, "healthy"))
ok, reason = imf.check_staging_image_fresh("orchestrator", "ORCH-058", "feature/ORCH-058-x")
assert ok is True
assert "abc123def456"[:12] in reason
def test_tc05_check_fail_closed_when_no_validated_revision(monkeypatch):
monkeypatch.setattr(imf.settings, "image_freshness_enabled", True)
monkeypatch.setattr(imf.settings, "image_freshness_repos", "")
monkeypatch.setattr(imf, "validated_revision", lambda r, b: "")
ok, reason = imf.check_staging_image_fresh("orchestrator", "ORCH-058", "feature/ORCH-058-x")
assert ok is False
assert "fail-closed" in reason.lower()
def test_tc05_check_fails_when_rebuild_fails(monkeypatch):
monkeypatch.setattr(imf.settings, "image_freshness_enabled", True)
monkeypatch.setattr(imf.settings, "image_freshness_repos", "")
monkeypatch.setattr(imf, "validated_revision", lambda r, b: "abc123def456")
monkeypatch.setattr(imf, "rebuild_staging_image", lambda r, b, s: (False, "build error"))
ok, reason = imf.check_staging_image_fresh("orchestrator", "ORCH-058", "feature/ORCH-058-x")
assert ok is False
assert "rebuild failed" in reason.lower()

53
tests/test_qg_checks.py Normal file
View File

@@ -0,0 +1,53 @@
"""ORCH-036 TC-15: the deploy-verdict parse contract is unchanged (AC-10).
``_parse_deploy_status`` reads ONLY the machine-readable ``deploy_status:`` YAML
frontmatter (never prose). ORCH-036 produces the verdict differently (a
deterministic finalizer instead of an LLM), but the parse contract that the gate
relies on must remain bit-identical:
SUCCESS -> (True, ...), FAILED -> (False, ...), no/!frontmatter -> (False, ...).
"""
import os
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
from src.qg.checks import _parse_deploy_status # noqa: E402
from src.self_deploy import build_deploy_log # noqa: E402
def test_tc15_success_frontmatter_passes():
ok, reason = _parse_deploy_status("---\ndeploy_status: SUCCESS\n---\n\nbody")
assert ok is True
assert "SUCCESS" in reason
def test_tc15_failed_frontmatter_fails():
ok, reason = _parse_deploy_status("---\ndeploy_status: FAILED\n---\n\nbody")
assert ok is False
assert "FAILED" in reason
def test_tc15_no_frontmatter_fails():
ok, _ = _parse_deploy_status("just prose, deploy_status: SUCCESS in text but no frontmatter")
assert ok is False
def test_tc15_missing_field_fails():
ok, _ = _parse_deploy_status("---\nother_field: SUCCESS\n---\n")
assert ok is False
def test_tc15_prose_success_word_does_not_pass():
"""Defensive: the word SUCCESS in prose must NOT satisfy the gate."""
ok, _ = _parse_deploy_status("# Deploy\n\nDeploy was a SUCCESS, hooray!\n")
assert ok is False
def test_tc15_finalizer_log_roundtrips_through_parser():
"""The finalizer's rendered log must be readable by the EXISTING parser —
SUCCESS passes, FAILED fails — proving the producer/consumer contract holds."""
ok_s, _ = _parse_deploy_status(build_deploy_log("ORCH-036", 0, "SUCCESS"))
ok_f, _ = _parse_deploy_status(build_deploy_log("ORCH-036", 2, "FAILED"))
assert ok_s is True
assert ok_f is False

View File

@@ -29,6 +29,7 @@ _EXPECTED_QGS = {
"check_deploy_status",
"check_staging_status",
"check_branch_mergeable", # ORCH-043 merge-gate (deploy-staging -> deploy edge)
"check_staging_image_fresh", # ORCH-058 image-freshness sub-gate (same edge)
}

View File

@@ -822,12 +822,18 @@ class TestMergeGate:
def test_tc20_pass_advances_to_deploy(self, monkeypatch):
"""TC-20 / AC-1: gate PASS (rebased + green) -> advance to deploy, deployer
enqueued, NO rollback. staging gate must pass first (same edge)."""
enqueued, NO rollback. staging gate must pass first (same edge).
ORCH-036: disable the manual-approve self-deploy interception so this test
keeps exercising the merge-gate in isolation (the executable self-deploy
Phase A path is covered separately in test_deploy_approve.py)."""
monkeypatch.setattr(stage_engine.settings, "deploy_require_manual_approve", False)
monkeypatch.setattr(
stage_engine, "QG_CHECKS",
{**stage_engine.QG_CHECKS,
"check_staging_status": _pass,
"check_branch_mergeable": _pass},
"check_branch_mergeable": _pass,
"check_staging_image_fresh": _pass},
)
task_id = _make_task("deploy-staging", repo="orchestrator", wi="ORCH-043",
branch="feature/ORCH-043-x")
@@ -987,6 +993,114 @@ class TestMergeGate:
assert _stage(task_id) == "deploy"
class TestImageFreshnessGate:
"""ORCH-058 TC-10/11: the image-freshness sub-gate on the deploy-staging ->
deploy edge. It runs AFTER staging-status + merge-gate, BEFORE Phase A."""
def _jobs_full(self):
conn = get_db()
rows = conn.execute(
"SELECT agent, task_content FROM jobs ORDER BY id"
).fetchall()
conn.close()
return [dict(r) for r in rows]
def test_tc10_stale_image_fails_fast_and_rolls_back(self, monkeypatch):
"""TC-10 / AC-1/AC-4: staging status + merge-gate green but the staging
image is STALE -> fail-fast: rollback to development, developer re-queued,
prod NEVER reached (no advance to deploy)."""
monkeypatch.setattr(stage_engine.settings, "deploy_require_manual_approve", False)
monkeypatch.setattr(
stage_engine, "QG_CHECKS",
{**stage_engine.QG_CHECKS,
"check_staging_status": _pass,
"check_branch_mergeable": _pass,
"check_staging_image_fresh": _fail(
"staging rebuild failed: health FAILED")},
)
task_id = _make_task("deploy-staging", repo="orchestrator", wi="ORCH-058",
branch="feature/ORCH-058-x")
res = advance_stage(
task_id, "deploy-staging", "orchestrator", "ORCH-058",
"feature/ORCH-058-x", finished_agent="deployer",
)
assert res.advanced is False
assert res.rolled_back_to == "development"
assert _stage(task_id) == "development" # never reached deploy
jobs = self._jobs_full()
assert len(jobs) == 1
assert jobs[0]["agent"] == "developer"
# The rollback task_desc carries the freshness reason for the developer.
assert "staging rebuild failed" in jobs[0]["task_content"]
def test_tc10_stale_rollback_respects_max_retries(self, monkeypatch):
"""AC-1: image-freshness rollback is capped by MAX_DEVELOPER_RETRIES —
4th attempt -> block + alert, no new developer job."""
monkeypatch.setattr(stage_engine.settings, "deploy_require_manual_approve", False)
monkeypatch.setattr(
stage_engine, "QG_CHECKS",
{**stage_engine.QG_CHECKS,
"check_staging_status": _pass,
"check_branch_mergeable": _pass,
"check_staging_image_fresh": _fail("provenance mismatch")},
)
task_id = _make_task("deploy-staging", repo="orchestrator", wi="ORCH-058",
branch="feature/ORCH-058-x")
_add_developer_runs(task_id, 3) # already at the cap
res = advance_stage(
task_id, "deploy-staging", "orchestrator", "ORCH-058",
"feature/ORCH-058-x", finished_agent="deployer",
)
assert res.rolled_back_to == "development"
assert stage_engine.set_issue_blocked.called
assert stage_engine.send_telegram.called
assert _jobs() == [] # no developer job past the cap
def test_tc11_fresh_image_advances_to_deploy(self, monkeypatch):
"""TC-11 / AC-1/AC-4: all three sub-checks green -> advance to deploy,
deployer enqueued, NO rollback (happy path)."""
monkeypatch.setattr(stage_engine.settings, "deploy_require_manual_approve", False)
monkeypatch.setattr(
stage_engine, "QG_CHECKS",
{**stage_engine.QG_CHECKS,
"check_staging_status": _pass,
"check_branch_mergeable": _pass,
"check_staging_image_fresh": _pass},
)
task_id = _make_task("deploy-staging", repo="orchestrator", wi="ORCH-058",
branch="feature/ORCH-058-x")
res = advance_stage(
task_id, "deploy-staging", "orchestrator", "ORCH-058",
"feature/ORCH-058-x", finished_agent="deployer",
)
assert res.advanced is True
assert res.to_stage == "deploy"
assert _stage(task_id) == "deploy"
assert res.rolled_back_to is None
jobs = _jobs()
assert len(jobs) == 1
assert jobs[0]["agent"] == "deployer"
def test_tc11_non_self_repo_skips_freshness_gate(self, monkeypatch):
"""Regression: for a non-self repo the REAL freshness gate is a no-op
(N/A), so deploy-staging -> deploy advances exactly as before ORCH-058."""
monkeypatch.setattr(stage_engine.settings, "deploy_require_manual_approve", False)
monkeypatch.setattr(
stage_engine, "QG_CHECKS",
{**stage_engine.QG_CHECKS,
"check_staging_status": _pass,
"check_branch_mergeable": _pass},
) # check_staging_image_fresh left REAL -> N/A for enduro-trails
task_id = _make_task("deploy-staging", repo="enduro-trails", wi="ET-099",
branch="feature/ET-099-x")
res = advance_stage(
task_id, "deploy-staging", "enduro-trails", "ET-099",
"feature/ET-099-x", finished_agent="deployer",
)
assert res.advanced is True
assert _stage(task_id) == "deploy"
class TestDelegation:
def test_launcher_calls_engine(self):
from src.agents.launcher import AgentLauncher

41
tests/test_stages.py Normal file
View File

@@ -0,0 +1,41 @@
"""ORCH-036 TC-16: STAGE_TRANSITIONS for deploy are unchanged (AC-10).
ORCH-036 only changes HOW the deploy verdict is produced (a deterministic
finalizer) — it must NOT touch the state machine. The deploy edge keeps its
exact transition (deploy -> done), no in-line agent (None), and the gate
``check_deploy_status``. The deploy-staging edge is likewise untouched.
"""
import os
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
from src.stages import ( # noqa: E402
STAGE_TRANSITIONS,
get_agent_for_stage,
get_next_stage,
get_qg_for_stage,
)
def test_tc16_deploy_transition_unchanged():
assert STAGE_TRANSITIONS["deploy"] == {
"next": "done", "agent": None, "qg": "check_deploy_status"
}
assert get_next_stage("deploy") == "done"
assert get_agent_for_stage("deploy") is None
assert get_qg_for_stage("deploy") == "check_deploy_status"
def test_tc16_deploy_staging_transition_unchanged():
assert STAGE_TRANSITIONS["deploy-staging"] == {
"next": "deploy", "agent": "deployer", "qg": "check_staging_status"
}
assert get_next_stage("deploy-staging") == "deploy"
assert get_agent_for_stage("deploy-staging") == "deployer"
assert get_qg_for_stage("deploy-staging") == "check_staging_status"
def test_tc16_done_is_terminal():
assert get_next_stage("done") is None

View File

@@ -0,0 +1,99 @@
"""ORCH-036 TC-11: the staging precondition is preserved (AC-8).
A red staging gate (``staging_status: FAILED``) must roll the task back to
development and NEVER let it reach the ``deploy`` stage — so the executable
prod self-deploy can never be initiated off a failed staging run. ORCH-036 adds
its Phase A interception AFTER ``check_staging_status``, so a staging failure
short-circuits before any self-deploy logic runs.
"""
import os
import tempfile
import pytest
_test_db = os.path.join(tempfile.gettempdir(), "test_orch_staging_precond.db")
os.environ["ORCH_DB_PATH"] = _test_db
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
from unittest.mock import MagicMock # noqa: E402
import src.db as _db # noqa: E402
from src.db import init_db, get_db # noqa: E402
from src import stage_engine # noqa: E402
from src import self_deploy # noqa: E402
from src.stage_engine import advance_stage # noqa: E402
@pytest.fixture(autouse=True)
def fresh_db(monkeypatch, tmp_path):
monkeypatch.setattr(_db.settings, "db_path", _test_db)
if os.path.exists(_test_db):
os.unlink(_test_db)
init_db()
monkeypatch.setattr(self_deploy.settings, "repos_dir", str(tmp_path))
monkeypatch.setattr(self_deploy.settings, "host_repos_dir", str(tmp_path))
yield
@pytest.fixture(autouse=True)
def silence_side_effects(monkeypatch):
for name in (
"notify_stage_change", "notify_qg_failure", "notify_approve_requested",
"send_telegram", "plane_notify_stage", "plane_notify_qg", "plane_add_comment",
"set_issue_in_review", "set_issue_needs_input", "set_issue_in_progress",
"set_issue_blocked", "set_issue_done",
):
monkeypatch.setattr(stage_engine, name, MagicMock())
def _make_task(stage, repo="orchestrator", branch="feature/ORCH-036-x", wi="ORCH-036"):
conn = get_db()
cur = conn.execute(
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
"VALUES (?, ?, ?, ?, ?)",
(f"plane-{wi}", wi, repo, branch, stage),
)
task_id = cur.lastrowid
conn.commit()
conn.close()
return task_id
def _stage(task_id):
conn = get_db()
row = conn.execute("SELECT stage FROM tasks WHERE id=?", (task_id,)).fetchone()
conn.close()
return row[0]
def _fail(reason):
def _f(*a, **k):
return (False, reason)
return _f
def test_tc11_staging_failed_never_reaches_deploy(monkeypatch):
monkeypatch.setattr(stage_engine.settings, "deploy_require_manual_approve", True)
monkeypatch.setattr(
stage_engine, "QG_CHECKS",
{**stage_engine.QG_CHECKS,
"check_staging_status": _fail("Staging status: FAILED")},
)
# Guard: a failed staging run must not trigger any self-deploy logic.
initiate = MagicMock()
monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", initiate)
task_id = _make_task("deploy-staging")
res = advance_stage(
task_id, "deploy-staging", "orchestrator", "ORCH-036",
"feature/ORCH-036-x", finished_agent="deployer",
)
assert res.advanced is False
assert res.rolled_back_to == "development"
assert _stage(task_id) == "development" # NEVER reached deploy
initiate.assert_not_called()
assert not self_deploy.has_marker("orchestrator", "ORCH-036", self_deploy.APPROVE_REQUESTED)