From 10f2a39a58530ce353458771f7956ff129b11e79 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Sat, 6 Jun 2026 20:05:35 +0000 Subject: [PATCH] feat(deploy): build-once SOURCE_IMAGE retag in hook + deploy-stage docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .openclaw/agents/deployer.md | 34 +++++++++++++++++++++++++---- CHANGELOG.md | 1 + scripts/orchestrator-deploy-hook.sh | 23 ++++++++++++++++++- 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/.openclaw/agents/deployer.md b/.openclaw/agents/deployer.md index 53611cb..6126307 100644 --- a/.openclaw/agents/deployer.md +++ b/.openclaw/agents/deployer.md @@ -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//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//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). --- diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b1724c..85a5829 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ## [Unreleased] ### Added +- **Исполняемый самодеплой стадии `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-файлы (`/.deploy-state-//`), без миграции БД. Условность как 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`. - **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 ` в догнанном 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`. - **Дословный текст findings reviewer/tester встраивается в `task_desc` заворота** (ORCH-046): при откате на `development` строка `task_desc` (попадает в `.task-dev.md` developer-агента) теперь несёт суть претензий, а не только ссылку на файл — устраняет «испорченный телефон», из-за которого агент шёл «читать файл», терял ключевые P0/P1 / причину FAIL и заворачивался снова, выжигая `MAX_DEVELOPER_RETRIES` и токены. Новый defensive-модуль `src/review_parse.py` (контракт «never raise», как `src/frontmatter.py`): `extract_review_findings(path)` — дословные пункты P0/P1 из секции `## Findings` файла `12-review.md`; `extract_test_failures(path)` — релевантный фрагмент тела `13-test-report.md` (приоритет `## Вывод pytest` → FAIL-строки `## Результаты` → `## Итог`). Обе функции усекают результат до `MAX_FINDINGS_CHARS`/`MAX_FAILURES_CHARS` (≈2000) с маркером `…(truncated)`. Две rollback-ветки `src/stage_engine.py` (reviewer REQUEST_CHANGES, tester `check_tests_passed` FAIL) встраивают извлечённый текст и **сохраняют ссылку** на полный файл («Полный контекст»); при пустом/битом артефакте — graceful-фоллбэк на прежнюю ссылку-строку (никаких исключений в `advance_stage`). Tester-ветка дополнительно всегда включает `reason` гейта. Последовательность отката, `_developer_retry_count`, поля `AdvanceResult` и реестр `QG_CHECKS` не менялись. ADR `docs/work-items/ORCH-046/06-adr/ADR-001-embed-findings-in-task-desc.md`. Тесты: `tests/test_review_parse.py`, `tests/test_stage_engine.py::TestRollbackTaskDescEmbedding`. diff --git a/scripts/orchestrator-deploy-hook.sh b/scripts/orchestrator-deploy-hook.sh index 3c23f42..c6d8ca8 100755 --- a/scripts/orchestrator-deploy-hook.sh +++ b/scripts/orchestrator-deploy-hook.sh @@ -9,6 +9,10 @@ # 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`). # LOG - log file path (default: /var/log/orchestrator/deploy-hook.log) # # Usage: @@ -25,6 +29,9 @@ 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:-}" # ---- Log setup ------------------------------------------------------------- LOG_DIR=/var/log/orchestrator @@ -139,10 +146,24 @@ 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 + 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