From 38065220412964235323b2ecd62fad5a1d748767 Mon Sep 17 00:00:00 2001 From: Slava Date: Sun, 7 Jun 2026 15:05:55 +0300 Subject: [PATCH 1/9] docs: init ORCH-061 business request --- docs/work-items/ORCH-061/00-business-request.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 docs/work-items/ORCH-061/00-business-request.md diff --git a/docs/work-items/ORCH-061/00-business-request.md b/docs/work-items/ORCH-061/00-business-request.md new file mode 100644 index 0000000..adb5d3b --- /dev/null +++ b/docs/work-items/ORCH-061/00-business-request.md @@ -0,0 +1,7 @@ +# Business Request: BUG: deploy-staging петля — откат на development (self-deploy) + +Work Item ID: ORCH-061 + +## Description + +TBD From 3ab2690a68af460970ef4956650c463c4fd019f4 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Sun, 7 Jun 2026 12:10:46 +0000 Subject: [PATCH 2/9] analyst(ET): auto-commit from analyst run_id=296 --- docs/work-items/ORCH-061/01-brd.md | 117 ++++++++++++++ docs/work-items/ORCH-061/02-trz.md | 145 +++++++++++++++++ .../ORCH-061/03-acceptance-criteria.md | 90 +++++++++++ docs/work-items/ORCH-061/04-test-plan.yaml | 147 ++++++++++++++++++ 4 files changed, 499 insertions(+) create mode 100644 docs/work-items/ORCH-061/01-brd.md create mode 100644 docs/work-items/ORCH-061/02-trz.md create mode 100644 docs/work-items/ORCH-061/03-acceptance-criteria.md create mode 100644 docs/work-items/ORCH-061/04-test-plan.yaml diff --git a/docs/work-items/ORCH-061/01-brd.md b/docs/work-items/ORCH-061/01-brd.md new file mode 100644 index 0000000..a8e2220 --- /dev/null +++ b/docs/work-items/ORCH-061/01-brd.md @@ -0,0 +1,117 @@ +# 01 — BRD: BUG — deploy-staging петля (откат deploy-staging → development) для self-deploy + +Work Item: **ORCH-061** +Тип: **BUG** +Приоритет: **P0** +Репозиторий: `orchestrator` (self-hosting) +Эпик-контекст: блокер **ORCH-54** (автономное внедрение self-hosting) + +--- + +## 1. Резюме (Executive summary) + +На стадии `deploy-staging` для self-hosting репозитория `orchestrator` задача +зацикливается: гейт ребра `deploy-staging → deploy` даёт FAILED, `stage_engine` +откатывает задачу `deploy-staging → development`, developer-агент перезапускается, +проходит конвейер заново, снова упирается в `deploy-staging`, снова откат — и так +по кругу (с расходом developer-ретраев и кредитов LLM), либо до исчерпания лимита +ретраев и блокировки. + +Следствие: **прод-деплой self-hosting репо невозможен автономно**. Последние +ORCH-задачи (ORCH-58, ORCH-60) доводились до прода **вручную** (ручной merge PR + +ручной build-once retag + ручной `--deploy`). Это прямой блокер автономного +внедрения (эпик ORCH-54). + +## 2. Бизнес-контекст и проблема + +Оркестратор дорабатывает сам себя (self-hosting). Стадия `deploy-staging` +(порт 8501) — обязательная страховка перед прод-деплоем орка (ORCH-35, ADR-0003). +На этой стадии deployer гоняет `scripts/staging_check.py` против живого +staging-стенда и пишет машинный вердикт `staging_status: SUCCESS|FAILED` в +`15-staging-log.md`. Гейт `check_staging_status` читает этот вердикт; `FAILED` +→ откат на `development` (ORCH-35, `_handle_qg_failure_rollbacks`). + +Подтверждены **две независимые причины** зацикливания. + +### Причина №1 — ложный FAILED `check_staging_status` (контекст ORCH-58) + +`staging_check.py` в sandbox-прогоне даёт **8/10 PASS, 2 ложных FAIL** на e2e-блоке +Block C: +- **C9a** — ветка не появляется в `orchestrator-sandbox` (branch not found); +- **C9b** — analyst-job не появляется в staging-очереди (`/queue → recent`). + +Сопутствующая пометка suite: «Plane comment check skipped: bot-tokens not added to +SANDBOX project» — bot-аккаунты агентов (`ORCH_PLANE_BOT_*`) не добавлены членами +SANDBOX-проекта Plane (проект создан после провижининга ботов). Это **отсутствие +sandbox-настроек инфраструктуры, а не регресс кода**. Тем не менее `staging_check.py` +возвращает ненулевой exit-code → deployer пишет `staging_status: FAILED` → гейт +FAILED → откат `deploy-staging → development`. + +### Причина №2 — «no changes to commit» на action-стадии (контекст ORCH-60) + +Стадии деплоя по своей природе **действие, а не правка кода** (рестарт/retag), и +закономерно не порождают git-изменений в `src/`/`tests/`. Сигнал «no changes» +для action-стадии не должен трактоваться как недовыполнение работы; критерий успеха +action-стадии — успешное выполнение действия (exit0 + доказанный health/staging), +а не наличие нового коммита. Сейчас отсутствие изменений на стадии деплоя приводит +к недопродвижению задачи и откату. + +### Совокупный эффект + +Любая из причин по отдельности достаточна, чтобы зациклить self-deploy. Обе +проявились на реальных задачах ORCH-58 и ORCH-60, которые пришлось доводить вручную. + +## 3. Цели (Goals) + +- **G1.** ORCH-задача для self-hosting `orchestrator` проходит + `deploy-staging → deploy → done` **без ручного вмешательства** и **без петли**. +- **G2.** Ложный (инфраструктурный) FAIL `staging_check` в sandbox **не вызывает** + откат `deploy-staging → development`. +- **G3.** Отсутствие git-изменений на стадиях деплоя (`deploy-staging` / `deploy`) + **не трактуется** как недовыполнение и не приводит к откату. +- **G4.** Реальный регресс (настоящий провал staging-проверки или прод-деплоя) + **по-прежнему** приводит к откату `→ development` (страховка не ослабляется). + +## 4. Вне области (Non-goals) + +- Полная автоматизация ручного approve прод-деплоя (это ORCH-54). +- Изменение конвейера стадий (`STAGE_TRANSITIONS`), реестра гейтов как структуры, + контрактов `check_deploy_status` / `check_staging_status` frontmatter-вердиктов. +- Изменение поведения для **не**-self-hosting репозиториев (enduro-trails и пр.): + для них staging-гейт и self-deploy остаются no-op / прежними. +- Изменение схемы БД. + +## 5. Заинтересованные стороны + +| Роль | Интерес | +|------|---------| +| Owner / оператор оркестратора | Автономный self-deploy без ручных шагов и без ночных петель. | +| Другие проекты (enduro-trails) | Их конвейер не должен быть затронут (общий инстанс, общая очередь). | +| Агенты (deployer) | Чёткий, не ложно-срабатывающий контракт стадии деплоя. | + +## 6. Кандидатные направления решения (из бизнес-запроса) + +Бизнес-запрос называет два направления (одно или оба); **выбор и механизм — +за архитектором (ADR)**, BRD требует лишь достижения G1–G4: + +- **(а)** Сделать sandbox-прогон `staging_check` честным (например, настроить + bot-токены SANDBOX Plane-проекта / починить sandbox e2e), чтобы C9a/C9b + проходили честно (10/10) и `check_staging_status` не падал ложно. +- **(б)** Отвязать продвижение стадий деплоя от git-changes для self-deploy: + успех action-стадии = exit0 + health/staging PASS, а не наличие коммита. + +## 7. Бизнес-эффект / риски бездействия + +- **Эффект:** разблокировка автономного внедрения self-hosting (ORCH-54); + устранение ручного труда (merge + retag + deploy) и риска ошибки при ручных шагах. +- **Риск бездействия:** каждая ORCH-задача требует ручного дотягивания до прода; + петли жгут кредиты LLM и developer-ретраи, задачи блокируются. + +## 8. Допущения + +- Прод-контейнер `orchestrator` (8500) обслуживает все проекты из общего инстанса — + его **нельзя** ронять/перезапускать в рамках задачи (см. CLAUDE.md, INFRA.md). +- Изменения касаются self-hosting пути (`is_self_hosting_repo` / `self_deploy_applies`); + для прочих репо поведение не меняется. +- Документация — golden source: затронутые `docs/architecture/README.md`, + `docs/operations/STAGING_CHECK.md`, `CHANGELOG.md` обновляются в том же PR. diff --git a/docs/work-items/ORCH-061/02-trz.md b/docs/work-items/ORCH-061/02-trz.md new file mode 100644 index 0000000..6a30f4d --- /dev/null +++ b/docs/work-items/ORCH-061/02-trz.md @@ -0,0 +1,145 @@ +# 02 — ТЗ: устранение петли deploy-staging → development при self-deploy + +Work Item: **ORCH-061** · Тип: **BUG** · Приоритет: **P0** · Репо: `orchestrator` + +> Это ТЗ фиксирует **требования и контракты**, которые должна удовлетворить +> реализация. Конкретный архитектурный механизм (направление (а), (б) или оба; +> где именно разместить логику) выбирает архитектор в ADR (`06-adr/`). +> ТЗ намеренно не предписывает дизайн, но задаёт инварианты и границы изменений. + +--- + +## 1. Затронутые модули `src/` и артефакты + +Прямо относящиеся к дефекту (для контекста; точечный набор правок — за архитектором): + +| Файл | Роль в дефекте | +|------|----------------| +| `scripts/staging_check.py` | e2e-suite; C9a (branch) / C9b (analyst job) дают ложный FAIL в sandbox; exit-code управляет вердиктом deployer. | +| `src/qg/checks.py` → `check_staging_status`, `_parse_staging_status` | гейт ребра `deploy-staging→deploy`; читает `staging_status:` из `15-staging-log.md`. | +| `src/stage_engine.py` → `advance_stage`, `_handle_qg_failure_rollbacks` | откат `deploy-staging→development` при FAILED (ветка `agent=="deployer" and qg=="check_staging_status"`). | +| `src/agents/launcher.py` → `_handle_completion`/`_try_advance_stage` | пост-ран git-commit; лог «no changes to commit»; обработка deployer-стадий. | +| `src/self_deploy.py` | Phase A/B/C исполняемого self-deploy (контекст продвижения `deploy`). | +| `src/config.py` | место для kill-switch/настроек нового поведения (если потребуется). | +| `.openclaw/agents/deployer.md` | инструкция deployer о написании вердикта; обновить при смене контракта. | +| `docs/operations/STAGING_CHECK.md`, `docs/architecture/README.md`, `CHANGELOG.md` | golden-source документация (обновить в том же PR). | + +## 2. Функциональные требования + +### FR-1 — Нет петли на корректном self-deploy +Для self-hosting `orchestrator`, при корректном состоянии (реальный pipeline в +порядке, staging-стенд здоров), задача проходит `deploy-staging → deploy → done` +**без отката** `deploy-staging → development` и **без ручного вмешательства**. + +### FR-2 — Ложный (инфраструктурный) FAIL не вызывает откат +Ложное падение `staging_check` в sandbox, вызванное **исключительно** отсутствием +sandbox-настроек (например, C9a/C9b при ненастроенных bot-токенах SANDBOX), не +приводит к `staging_status: FAILED` → откату. Должно быть реализовано одним из +способов (выбор — ADR): +- **(а)** sandbox-инфраструктура приведена в состояние, при котором C9a/C9b + проходят честно (10/10); и/или +- **(б)** вердикт staging-гейта перестаёт зависеть от заведомо инфраструктурных + (не пайплайновых) проверок — например, осознанный allowlist/threshold + «известных sandbox-инфра» проверок, отделённый от реальных pipeline-проверок. + +> Любой механизм по FR-2 **обязан** сохранить FR-4 (реальный провал ловится). + +### FR-3 — «no changes» на action-стадии не есть недовыполнение +На стадиях деплоя (`deploy-staging`, `deploy`) для self-deploy отсутствие +git-изменений (`no changes to commit`) **не** трактуется как недовыполнение и +**не** приводит к откату/блокировке. Критерий успеха action-стадии = успешный +exit агента/хука + доказанный health/staging-вердикт, а **не** наличие нового +коммита. + +### FR-4 — Реальный регресс по-прежнему откатывается (страховка цела) +- Настоящий провал реальных pipeline-проверок staging → `staging_status: FAILED` + → откат `deploy-staging → development` (как сейчас). +- Настоящий провал прод-деплоя (`deploy_status: FAILED`, БАГ-8) → откат + `deploy → development` (как сейчас). +- Ослабления страховки быть не должно: «зелёный по умолчанию» при недоступности + проверок запрещён (fail-closed для реальных проверок сохраняется). + +### FR-5 — Условность self-hosting сохранена +Изменения активны **только** для self-hosting пути +(`is_self_hosting_repo` / `self_deploy_applies`). Для прочих репозиториев +поведение `check_staging_status` (no-op N/A) и стадии деплоя — **без изменений**. + +### FR-6 — Управляемость (kill-switch) +Любое новое поведение (толерантность к инфра-FAIL и/или отвязка от git-changes) +закрыто отдельным флагом конфигурации (по образцу `merge_gate_enabled`, +`image_freshness_enabled`, `self_deploy_enabled`), с безопасным дефолтом и +возможностью мгновенно вернуть прежнее поведение без передеплоя кода-логики. + +### FR-7 — Наблюдаемость +Срабатывание нового поведения (например, «staging_check: проигнорирован +инфра-FAIL C9a/C9b» или «action-стадия: no-changes ожидаемо») логируется явной +строкой и при необходимости отражается в Plane-комментарии/Telegram, чтобы +оператор отличал «реальный зелёный» от «зелёного с допущением». + +## 3. Изменения API + +API эндпоинты (`/health`, `/status`, `/queue`, `/webhook/*`) — **без изменений**. +Допускается расширение снапшота `GET /queue` диагностическим полем (опционально, +по решению архитектора) — без удаления/переименования существующих ключей. + +## 4. Изменения схемы БД + +**Нет.** Схема (`events`, `tasks`, `agent_runs`, `jobs`) не меняется. Любое +restart-safe состояние (если потребуется) — через существующие паттерны +(sentinel-файлы / поля `jobs.task_content`), без миграций. + +## 5. Контракты, которые НЕЛЬЗЯ менять + +- `STAGE_TRANSITIONS` (порядок и состав стадий) и `get_previous_stage`. +- Состав/семантика `QG_CHECKS` как реестра; frontmatter-контракты + `staging_status:` (`15-staging-log.md`) и `deploy_status:` (`14-deploy-log.md`) — + читаются ТОЛЬКО из YAML-frontmatter, значения `SUCCESS|FAILED`. +- Откатные контракты БАГ-8 (`deploy→development`) и ORCH-35 + (`deploy-staging→development`) для **реальных** провалов. +- Контракт exit-code хука деплоя (`0/1/2`) и `map_exit_code_to_status`. +- Поведение для не-self-hosting репозиториев. + +## 6. Требования к новым/изменённым QG checks + +- Если выбран механизм толерантности (FR-2 вариант б), он реализуется **внутри** + существующего пути `check_staging_status` / staging-вердикта (не новая стадия), + по образцу условности ORCH-35; контракт «never-raise» сохраняется. +- Любая новая проверка/под-чек регистрируется в `QG_CHECKS` и покрывается + снапшот-тестом реестра (`tests/test_qg_registry_snapshot.py`). + +## 7. Требования к staging_check.py (если затрагивается) + +- Если выбран механизм классификации проверок (FR-2 вариант б через suite), + e2e-проверки, заведомо зависящие от sandbox-инфраструктуры (C9a/C9b и связанные), + должны быть **отличимы** (по метке/категории) от реальных pipeline-проверок, + чтобы вердикт и/или exit-code мог их учитывать осознанно. Прежний дефолтный + режим (`stub`/`full-real`) и существующие проверки A/B сохраняются. +- Никакого «всегда 0»: реальный провал реальных проверок обязан давать ненулевой + exit-code / FAIL-категорию. + +## 8. Требования к pipeline-артефактам + +- Стадия деплоя по-прежнему производит машинный вердикт-артефакт + (`15-staging-log.md` / `14-deploy-log.md`) с корректным frontmatter. +- Артефакты, обновляемые по pipeline в этом PR: `docs/architecture/README.md` + (раздел про staging-гейт/self-deploy — отметить ORCH-061), + `docs/operations/STAGING_CHECK.md` (поведение C9a/C9b и/или sandbox-настройка), + `CHANGELOG.md`, при изменении контракта — `.openclaw/agents/deployer.md`. +- ADR: `docs/work-items/ORCH-061/06-adr/ADR-001-*.md` (решение по направлению/механизму). + +## 9. Нефункциональные требования + +- **Безопасность self-hosting:** реализация НЕ перезапускает/не роняет прод 8500 + в рамках задачи; сборки/recreate — только staging (8501). +- **Идемпотентность / restart-safe:** новое поведение переживает рестарт инстанса. +- **never-raise:** дефект-исправляющая логика не должна пробрасывать исключения в + `advance_stage` (по образцу merge-gate / image-freshness). +- **Обратная совместимость:** при выключенном флаге (FR-6) — прежнее поведение 1:1. +- **Тестируемость:** «чистая» вердикт-логика выделяется так, чтобы покрываться + unit-тестами без live staging/docker. + +## 10. Зависимости и связанные задачи + +- ORCH-35 (условный staging-гейт, ADR-0003), ORCH-36 (исполняемый self-deploy, + ADR-0007), ORCH-58 (провенанс staging-образа), ORCH-60 (skip escalated/Blocked). +- Блокирует: ORCH-54 (автономное внедрение). diff --git a/docs/work-items/ORCH-061/03-acceptance-criteria.md b/docs/work-items/ORCH-061/03-acceptance-criteria.md new file mode 100644 index 0000000..66c4e34 --- /dev/null +++ b/docs/work-items/ORCH-061/03-acceptance-criteria.md @@ -0,0 +1,90 @@ +# 03 — Критерии приёмки: ORCH-061 + +Work Item: **ORCH-061** · Тип: **BUG** · Приоритет: **P0** + +Формат: каждый критерий имеет чёткое условие **PASS/FAIL**. Критерии outcome-ориентированы +(не предписывают механизм); реализация может удовлетворить FR-2 направлением (а), (б) или обоими. + +--- + +## AC-1 — Автономный проход self-deploy без петли (главный критерий) +- **PASS:** для self-hosting `orchestrator` задача в состоянии `deploy-staging` + при здоровом стенде и корректном pipeline продвигается `deploy-staging → deploy` + (далее по штатному approve → `done`) **без** отката на `development` и **без** + ручного вмешательства в шаги staging/merge/retag/deploy. +- **FAIL:** наблюдается хотя бы один автоматический откат `deploy-staging → development` + при отсутствии реального регресса, либо для прохода требуется ручной шаг. + +## AC-2 — Ложный инфраструктурный FAIL не откатывает +- **PASS:** прогон, где **единственные** падения — заведомо sandbox-инфраструктурные + (C9a branch-not-found / C9b analyst-job-not-in-queue при ненастроенных bot-токенах + SANDBOX), а все реальные pipeline-проверки зелёные, приводит к + `staging_status: SUCCESS` (или эквивалентному «не-FAILED») → **нет** отката. +- **FAIL:** такой прогон даёт `staging_status: FAILED` → откат `deploy-staging → development`. + +## AC-3 — Реальный провал staging по-прежнему откатывает (страховка цела) +- **PASS:** прогон с провалом **реальной** pipeline-проверки (не инфра-исключение) + даёт `staging_status: FAILED` → откат `deploy-staging → development` + + `set_issue_blocked`/нотификации (как сейчас, ORCH-35). +- **FAIL:** реальный провал staging проходит как успех / задача доходит до `deploy`. + +## AC-4 — «no changes to commit» на action-стадии не есть недовыполнение +- **PASS:** на стадиях `deploy-staging`/`deploy` для self-deploy отсутствие + git-изменений не вызывает откат/блокировку; продвижение определяется успешным + exit + health/staging-вердиктом. +- **FAIL:** отсутствие коммита на стадии деплоя приводит к откату/недопродвижению. + +## AC-5 — Реальный провал прод-деплоя по-прежнему откатывает (БАГ-8 цел) +- **PASS:** `deploy_status: FAILED` (exit-code хука ≠ 0) → откат `deploy → development` + + `set_issue_blocked` + release merge-lease + clear deploy-state (как сейчас). +- **FAIL:** провал прод-деплоя проходит как `done`. + +## AC-6 — Условность self-hosting сохранена +- **PASS:** для не-self-hosting репо (`is_self_hosting_repo == False`) + `check_staging_status` остаётся `(True, "Staging gate N/A …")`, стадия деплоя + работает как прежде; поведение этих репо байт-в-байт не изменилось. +- **FAIL:** изменилось поведение для не-self-hosting репозиториев. + +## AC-7 — Kill-switch возвращает прежнее поведение +- **PASS:** при выключенном флаге нового поведения (FR-6) система ведёт себя 1:1 + как до ORCH-061 (включая прежний откат на инфра-FAIL, если флаг выключен). +- **FAIL:** новое поведение невозможно отключить / выключение не восстанавливает старое. + +## AC-8 — Контракты не сломаны +- **PASS:** `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, frontmatter-контракты + `staging_status:`/`deploy_status:` (только YAML, `SUCCESS|FAILED`), exit-code хука + (0/1/2) и `map_exit_code_to_status` — без регресса; снапшот-тест реестра гейтов зелёный. +- **FAIL:** изменены контракты стадий/гейтов/вердиктов или сломан снапшот реестра. + +## AC-9 — Схема БД не меняется +- **PASS:** нет миграций; `events`/`tasks`/`agent_runs`/`jobs` без изменений схемы. +- **FAIL:** добавлена/изменена колонка/таблица. + +## AC-10 — never-raise +- **PASS:** новая логика в пути `advance_stage`/staging-вердикта при любой внутренней + ошибке (docker/ssh/io/парсинг) даёт безопасный детерминированный вердикт и не + пробрасывает исключение в `advance_stage`. +- **FAIL:** исключение из новой логики всплывает в `advance_stage`/останавливает конвейер. + +## AC-11 — Наблюдаемость +- **PASS:** срабатывание нового поведения (игнор инфра-FAIL / ожидаемые no-changes) + даёт явную лог-строку (и при необходимости коммент/Telegram), позволяющую отличить + «честно зелёный» от «зелёного с допущением». +- **FAIL:** новое поведение срабатывает молча, неотличимо от честного зелёного. + +## AC-12 — Безопасность self-hosting +- **PASS:** реализация не перезапускает/не роняет прод-контейнер 8500 в рамках + задачи; любые сборки/recreate — только staging (8501). +- **FAIL:** код пути задачи рестартит/собирает прод 8500. + +## AC-13 — Документация обновлена (golden source) +- **PASS:** в том же PR обновлены `docs/architecture/README.md`, + `docs/operations/STAGING_CHECK.md` (поведение C9a/C9b и/или sandbox-настройка), + `CHANGELOG.md`, и (при смене контракта) `.openclaw/agents/deployer.md`; заведён + ADR `docs/work-items/ORCH-061/06-adr/ADR-001-*.md`. +- **FAIL:** функционал изменён без обновления документации/ADR. + +## AC-14 — Регрессионные тесты зелёные +- **PASS:** `pytest tests/ -q` проходит полностью; новые тесты из `04-test-plan.yaml` + присутствуют и зелёные; существующие staging/deploy/qg/stage_engine тесты не упали. +- **FAIL:** любой тест из плана отсутствует или красный. diff --git a/docs/work-items/ORCH-061/04-test-plan.yaml b/docs/work-items/ORCH-061/04-test-plan.yaml new file mode 100644 index 0000000..d1b1d50 --- /dev/null +++ b/docs/work-items/ORCH-061/04-test-plan.yaml @@ -0,0 +1,147 @@ +work_item: ORCH-061 +title: "BUG: deploy-staging петля — откат на development (self-deploy)" +description: > + План тестов на устранение зацикливания deploy-staging -> development для + self-hosting orchestrator. Покрывает обе подтверждённые причины: (1) ложный + FAILED check_staging_status из-за заведомо инфраструктурных C9a/C9b в sandbox; + (2) трактовку "no changes to commit" на action-стадии как недовыполнения. + Тесты outcome-ориентированы и не предписывают механизм: часть кейсов помечена + как mechanism-dependent (а=sandbox-инфра честно, б=толерантность/отвязка) — + финальный набор подтверждает архитектор в ADR; реализуются тесты под выбранный + механизм. Инвариант страховки (реальный регресс откатывает) и условность + self-hosting проверяются ВСЕГДА. +tests: + # --- Главный сценарий: нет петли ---------------------------------------- + - id: TC-01 + type: unit + description: > + Корректный self-deploy: при staging_status SUCCESS и пройденном merge/freshness + sub-gate advance_stage(deploy-staging, finished_agent=deployer) продвигает к + deploy (Phase A approval-pending), НЕ откатывает на development. (AC-1) + module: tests/test_stage_engine.py + expected: PASS + + - id: TC-02 + type: unit + description: > + Регресс-страховка ORCH-35: реальный провал реальной pipeline-проверки -> + staging_status FAILED -> advance_stage откатывает deploy-staging -> development + + set_issue_blocked. (AC-3) + module: tests/test_stage_engine.py + expected: PASS + + # --- Причина №1: ложный инфраструктурный FAIL --------------------------- + - id: TC-03 + type: unit + description: > + Классификация проверок staging_check: проверки, заведомо зависящие от + sandbox-инфраструктуры (C9a/C9b), отличимы (метка/категория) от реальных + pipeline-проверок. Чистая логика классификации/вердикта тестируется без + live staging/docker. (AC-2, mechanism-dependent: вариант б) + module: tests/test_staging_check_b6.py + expected: PASS + + - id: TC-04 + type: unit + description: > + Вердикт-логика: все реальные проверки PASS, падают ТОЛЬКО известные + sandbox-инфра проверки (C9a/C9b) -> итог не-FAILED (нет ложного отката). + (AC-2) + module: tests/test_qg_checks.py + expected: PASS + + - id: TC-05 + type: unit + description: > + Вердикт-логика: падает хотя бы одна РЕАЛЬНАЯ pipeline-проверка (помимо инфра) + -> итог FAILED (страховка не ослаблена, fail-closed). (AC-3) + module: tests/test_qg_checks.py + expected: PASS + + # --- Причина №2: no changes на action-стадии ---------------------------- + - id: TC-06 + type: unit + description: > + На action-стадии (deploy-staging/deploy) для self-deploy отсутствие + git-изменений ("no changes to commit") НЕ приводит к откату/недопродвижению; + продвижение определяется exit + вердиктом, а не наличием коммита. (AC-4) + module: tests/test_launcher.py + expected: PASS + + - id: TC-07 + type: unit + description: > + На code-стадии (development) отсутствие изменений всё ещё обрабатывается + прежним образом (нет ложного "успеха" там, где код должен был измениться) — + изменение FR-3 не протекает на не-action стадии. (AC-4, regression-guard) + module: tests/test_launcher.py + expected: PASS + + # --- Условность self-hosting -------------------------------------------- + - id: TC-08 + type: unit + description: > + Для не-self-hosting репо check_staging_status остаётся (True, "Staging gate + N/A …") и новое поведение НЕ активируется; поведение этих репо неизменно. + (AC-6, FR-5) + module: tests/test_qg.py + expected: PASS + + # --- Kill-switch / обратная совместимость ------------------------------- + - id: TC-09 + type: unit + description: > + При выключенном флаге нового поведения (FR-6) система ведёт себя 1:1 как до + ORCH-061: инфра-FAIL снова приводит к FAILED/откату. Дефолт флага безопасен. + (AC-7) + module: tests/test_config.py + expected: PASS + + # --- БАГ-8: реальный провал прод-деплоя ---------------------------------- + - id: TC-10 + type: unit + description: > + deploy_status FAILED (exit-code хука != 0) -> откат deploy -> development + + set_issue_blocked + release merge-lease + clear deploy-state (БАГ-8 не сломан). + (AC-5) + module: tests/test_deploy_rollback.py + expected: PASS + + # --- Контракты / реестр / never-raise ----------------------------------- + - id: TC-11 + type: unit + description: > + Снапшот реестра QG_CHECKS и STAGE_TRANSITIONS не изменён неожиданно; + frontmatter-контракты staging_status/deploy_status (SUCCESS|FAILED, только + YAML) сохранены. (AC-8) + module: tests/test_qg_registry_snapshot.py + expected: PASS + + - id: TC-12 + type: unit + description: > + never-raise: новая логика staging-вердикта/advance при внутренней ошибке + (io/парсинг/docker/ssh) возвращает безопасный детерминированный вердикт и не + пробрасывает исключение в advance_stage. (AC-10) + module: tests/test_stage_engine.py + expected: PASS + + # --- Интеграционный сквозной сценарий ------------------------------------ + - id: TC-13 + type: integration + description: > + Сквозной self-deploy на тестовой БД: задача deploy-staging при здоровом + стенде с инфра-only недочётами проходит deploy-staging -> deploy (Phase A) -> + (approve) -> deploy финализация SUCCESS -> done, БЕЗ единого отката на + development в логе переходов. (AC-1, AC-4) + module: tests/test_stage_engine.py + expected: PASS + + - id: TC-14 + type: integration + description: > + Наблюдаемость: при срабатывании нового поведения (игнор инфра-FAIL / + ожидаемые no-changes) присутствует явная лог-строка/диагностика, отличающая + "честно зелёный" от "зелёного с допущением". (AC-11) + module: tests/test_stage_engine.py + expected: PASS From 1d1208c13635ce72c7f1d6df50d3a56bbc579479 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Sun, 7 Jun 2026 12:22:46 +0000 Subject: [PATCH 3/9] architect(ET): auto-commit from architect run_id=297 --- docs/architecture/README.md | 12 +- docs/architecture/adr/README.md | 8 + .../adr/adr-0009-staging-infra-tolerance.md | 56 +++++ .../06-adr/ADR-001-staging-infra-tolerance.md | 222 ++++++++++++++++++ .../ORCH-061/07-infra-requirements.md | 37 +++ .../ORCH-061/08-data-requirements.md | 20 ++ docs/work-items/ORCH-061/10-tech-risks.md | 23 ++ 7 files changed, 377 insertions(+), 1 deletion(-) create mode 100644 docs/architecture/adr/adr-0009-staging-infra-tolerance.md create mode 100644 docs/work-items/ORCH-061/06-adr/ADR-001-staging-infra-tolerance.md create mode 100644 docs/work-items/ORCH-061/07-infra-requirements.md create mode 100644 docs/work-items/ORCH-061/08-data-requirements.md create mode 100644 docs/work-items/ORCH-061/10-tech-risks.md diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 1cc866f..c578e55 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -42,6 +42,16 @@ created → analysis → architecture → development → review → testing → ### Условный staging-гейт (ORCH-35) `check_staging_status` реален только для self-hosting (`is_self_hosting_repo(repo)` → `orchestrator`); для остальных проектов → no-op `(True, "Staging gate N/A")`. Для orchestrator парсит `staging_status:` из `15-staging-log.md`; FAILED → откат на `development`. Подробнее: [ADR-0003](adr/adr-0003-staging-gate.md). +### Толерантность staging-вердикта к инфра-FAIL (ORCH-061 — design) +Self-hosting зацикливался на `deploy-staging`: `scripts/staging_check.py` давал ложный FAILED на C9a/C9b (ветка в sandbox / analyst-job в очереди), вызванный **отсутствием sandbox-настроек** (bot-аккаунты не члены SANDBOX-проекта), а не регрессом кода → откат `deploy-staging → development` → петля. ORCH-061 классифицирует проверки suite на **REAL** (pipeline) и **SANDBOX_INFRA** (узкий allowlist `{C9a, C9b}`) и делает вердикт толерантным к инфра-FAIL, сохраняя fail-closed для реальных проверок: +- Чистая логика — leaf-модуль `src/staging_verdict.py` (`classify_check`, `compute_staging_verdict`, never-raise). Упала хоть одна REAL → FAILED/exit1; упали ТОЛЬКО SANDBOX_INFRA и толерантность вкл → SUCCESS/exit0 (waived); waiver применяется только когда все REAL (вкл. C7/C8) зелёные. +- `scripts/staging_check.py` помечает проверки категориями, считает вердикт через `staging_verdict`, печатает `INFRA-WAIVED` (наблюдаемость). +- Kill-switch `staging_infra_tolerance_enabled` (env `ORCH_STAGING_INFRA_TOLERANCE_ENABLED`, дефолт `true`, в `.env.staging`); `false` → 1:1 прежнее строгое поведение. +- `check_staging_status` / `_parse_staging_status` / `STAGE_TRANSITIONS` / реестр `QG_CHECKS` — **без изменений** (новый QG-чек не вводится); условность ORCH-35 и схема БД сохранены. +- Инвариант: «no changes to commit» на action-стадиях (`deploy-staging`/`deploy`) не есть недовыполнение — продвижение определяется exit0 + гейт-вердиктом (launcher не откатывает; добавлена observability-строка). + +Подробнее: [adr-0009](adr/adr-0009-staging-infra-tolerance.md), детально — `docs/work-items/ORCH-061/06-adr/ADR-001-staging-infra-tolerance.md`. + ### Merge-gate: догон `main` + re-test + сериализация слияний (ORCH-043) Детерминированный под-гейт (`check_branch_mergeable`, без LLM) на ребре **`deploy-staging → deploy`**: исполняется ПОСЛЕ `check_staging_status` и ДО запуска deployer'а, который вливает PR в `main` (deployer мержит в начале стадии `deploy`). Стадии (`STAGE_TRANSITIONS`) НЕ меняются — это «под-гейт» ребра, а не отдельная стадия (триггер — то же событие «staging-deployer завершился»). @@ -201,4 +211,4 @@ never-raise на единицу работы; тишина при синхрон Схема БД, потоки данных, resilience-слой, детали Dockerfile — [internals.md](internals.md). --- -*Актуально на 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-060 (F-1 skip escalated/Blocked/Needs-Input, `docs/work-items/ORCH-060/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-060 (Guard 1 `developer_retry_count>=MAX_DEVELOPER_RETRIES` + Guard 2 `plane_sync.fetch_issue_state` Blocked/Needs-Input, флаг `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED`); 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).* +*Актуально на 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-060 (F-1 skip escalated/Blocked/Needs-Input, `docs/work-items/ORCH-060/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-060 (Guard 1 `developer_retry_count>=MAX_DEVELOPER_RETRIES` + Guard 2 `plane_sync.fetch_issue_state` Blocked/Needs-Input, флаг `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED`); 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); ORCH-061 (толерантность staging-вердикта к инфра-FAIL C9a/C9b, adr-0009, `docs/work-items/ORCH-061/06-adr/ADR-001`) — design, ветка feature/ORCH-061 (обновлять также при изменении src/staging_verdict.py, scripts/staging_check.py, флаг staging_infra_tolerance_enabled).* diff --git a/docs/architecture/adr/README.md b/docs/architecture/adr/README.md index 529ac45..cac6d70 100644 --- a/docs/architecture/adr/README.md +++ b/docs/architecture/adr/README.md @@ -12,6 +12,14 @@ Per-work-item решения живут в `docs/work-items//06-adr/ADR-NNN- | adr-0005 | Контейнеры бегут под uid:gid хоста (1000:1000) | accepted | 2026-06-06 | ORCH-040 | | adr-0006 | Merge-gate (догон main + re-test + сериализация слияний) | proposed | 2026-06-06 | ORCH-043 | | adr-0007 | Reconciler застрявших стадий (sweeper потерянных webhook) | accepted | 2026-06-06 | ORCH-053 | +| adr-0007 | Исполняемый самодеплой стадии `deploy` (файл adr-0007-executable-self-deploy) | accepted | 2026-06-06 | ORCH-036 | +| adr-0008 | Провенанс staging-образа перед BUILD-ONCE retag | accepted | 2026-06-06 | ORCH-058 | +| adr-0009 | Толерантность staging-вердикта к инфраструктурным FAIL | accepted | 2026-06-07 | ORCH-061 | + +> ⚠️ Историческая коллизия: номер `0007` занят двумя файлами — +> `adr-0007-reconciler.md` (ORCH-053) и `adr-0007-executable-self-deploy.md` +> (ORCH-036). Оба accepted; для новых сквозных ADR использовать следующий +> свободный номер (текущий максимум — `0009`). ## Формат **Контекст → Решение → Альтернативы → Последствия → Связи.** Статус: proposed / accepted / superseded. diff --git a/docs/architecture/adr/adr-0009-staging-infra-tolerance.md b/docs/architecture/adr/adr-0009-staging-infra-tolerance.md new file mode 100644 index 0000000..5b576f0 --- /dev/null +++ b/docs/architecture/adr/adr-0009-staging-infra-tolerance.md @@ -0,0 +1,56 @@ +# adr-0009: Толерантность staging-вердикта к заведомо инфраструктурным FAIL + +- **Статус:** accepted +- **Дата:** 2026-06-07 +- **Задача:** ORCH-061 +- **Детально:** `docs/work-items/ORCH-061/06-adr/ADR-001-staging-infra-tolerance.md` + +## Контекст +Self-hosting `orchestrator` зацикливался на `deploy-staging`: `staging_check.py` +давал 2 ложных FAIL (C9a — ветка в sandbox, C9b — analyst-job в очереди), вызванных +отсутствием sandbox-настроек (bot-аккаунты не члены SANDBOX-проекта), а не регрессом +кода. `staging_check.py` делал `sys.exit(1)` при любом FAIL → deployer писал +`staging_status: FAILED` → `check_staging_status` FAILED → откат `deploy-staging → +development` → петля (жгла developer-ретраи и кредиты). Прод-деплой орка приходилось +доводить вручную — блокер автономного внедрения (ORCH-54). + +## Решение +Классифицировать проверки staging-suite на **REAL** (pipeline) и **SANDBOX_INFRA** +(заведомо инфраструктурные, узкий allowlist `{C9a, C9b}`) и сделать вердикт +толерантным к инфра-FAIL, сохранив fail-closed для реальных проверок: + +- Новый leaf-модуль `src/staging_verdict.py` (pure, never-raise, stdlib): + `classify_check(label)` + `compute_staging_verdict(items, infra_tolerant)`. + Правило: упала хоть одна REAL → FAILED/exit1; упали ТОЛЬКО SANDBOX_INFRA и + толерантность вкл → SUCCESS/exit0 (waived); толерантность выкл → legacy strict + (любой FAIL → FAILED). +- `scripts/staging_check.py` помечает проверки категориями, считает вердикт через + `staging_verdict`, печатает `INFRA-WAIVED` при вайвере (наблюдаемость). +- Kill-switch `staging_infra_tolerance_enabled` (env + `ORCH_STAGING_INFRA_TOLERANCE_ENABLED`, дефолт `True`; в `.env.staging`). +- `check_staging_status` / `_parse_staging_status` / `STAGE_TRANSITIONS` / реестр + `QG_CHECKS` — **без изменений**; новый QG-чек не вводится. Условность ORCH-35 + сохранена (не-self → no-op N/A). +- Инвариант FR-3: «no changes to commit» на action-стадиях (`deploy-staging`/`deploy`) + не есть недовыполнение — продвижение определяется exit0 + гейт-вердиктом + (launcher уже не откатывает; добавлена observability-строка). + +## Альтернативы +- Только починить sandbox-инфру (направление а) — хрупко, не структурно, вне + автономной досягаемости таска; оставлено как опциональное hardening. +- «Зелёный по умолчанию» при недоступности проверок — запрещён (fail-closed). +- Новый QG-чек / структурный артефакт `15-staging-log.md` — избыточно, меняло бы + контракты/реестр; толерантность размещена в suite до артефакта. + +## Последствия +- Петля устранена; страховка цела (реальный регресс → FAILED → откат). +- Чистая вердикт-логика юнит-тестируема без live staging/docker. +- Контракты гейтов/стадий/вердиктов/реестра и схема БД неизменны. +- Риск: узкое окно — реальный регресс именно в создании ветки/постановке + analyst-job может быть заваивен; митигировано allowlist'ом `{C9a,C9b}` + условием + «все REAL (вкл. C7/C8) зелёные» + INFRA-WAIVED-логом. Разблокирует ORCH-54. + +## Связи +adr-0003 (условный staging-гейт — база `is_self_hosting_repo` / `check_staging_status`), +adr-0006 (merge-gate), adr-0007 (исполняемый self-deploy), adr-0008 (провенанс +staging-образа). Блокирует ORCH-54. diff --git a/docs/work-items/ORCH-061/06-adr/ADR-001-staging-infra-tolerance.md b/docs/work-items/ORCH-061/06-adr/ADR-001-staging-infra-tolerance.md new file mode 100644 index 0000000..ac68910 --- /dev/null +++ b/docs/work-items/ORCH-061/06-adr/ADR-001-staging-infra-tolerance.md @@ -0,0 +1,222 @@ +# ADR-001 — Толерантность staging-вердикта к инфра-FAIL + инвариант «no-changes на action-стадии» + +- **Статус:** Accepted +- **Дата:** 2026-06-07 +- **Задача:** ORCH-061 (BUG, P0) · Репо: `orchestrator` (self-hosting) +- **Связи:** ORCH-35/adr-0003 (условный staging-гейт), ORCH-36/adr-0007 (исполняемый self-deploy), ORCH-58/adr-0008 (провенанс staging-образа), ORCH-43/adr-0006 (merge-gate); блокирует ORCH-54. +- **Сквозной ADR:** [adr-0009-staging-infra-tolerance](../../../architecture/adr/adr-0009-staging-infra-tolerance.md) + +--- + +## Контекст + +На стадии `deploy-staging` self-hosting `orchestrator` зацикливается: +`check_staging_status` даёт FAILED → `_handle_qg_failure_rollbacks` откатывает +`deploy-staging → development` → developer перезапускается → конвейер заново → +снова `deploy-staging` → снова FAILED. Петля жжёт developer-ретраи и LLM-кредиты, +а прод-деплой орка приходится доводить вручную (ORCH-58, ORCH-60). Это прямой +блокер автономного внедрения (ORCH-54). + +Подтверждены две независимые причины (BRD §2): + +**Причина №1 — ложный FAILED.** `scripts/staging_check.py` в sandbox даёт +8/10 PASS, 2 ложных FAIL на e2e-блоке C: +- **C9a** — ветка не появляется в `orchestrator-sandbox`; +- **C9b** — analyst-job не появляется в staging-очереди. + +Оба завязаны на отсутствие sandbox-настроек (bot-аккаунты `ORCH_PLANE_BOT_*` не +добавлены членами SANDBOX-проекта — проект создан после провижининга ботов). Это +**отсутствие инфраструктуры sandbox, а не регресс кода**. Но `staging_check.py` +суммирует `all_ok = passed == total` и делает `sys.exit(1)` при любом FAIL → +deployer пишет `staging_status: FAILED` → откат. + +**Причина №2 — «no changes to commit» на action-стадии.** Стадии деплоя по природе +действие (рестарт/retag), а не правка `src/`. Отсутствие git-изменений не должно +трактоваться как недовыполнение; критерий успеха action-стадии — exit0 + +health/staging-вердикт, а не наличие коммита. + +### Что есть сейчас в коде (точки дефекта) + +- `scripts/staging_check.py`: `Results.summary()` → `all_ok = passed == total`; + `main()` → `sys.exit(0 if all_ok else 1)`. Все проверки равнозначны — инфра-FAIL + неотличим от регресса. +- `src/qg/checks.py` → `check_staging_status` / `_parse_staging_status`: читает + `staging_status:` (SUCCESS|FAILED) из `15-staging-log.md`. Условный (ORCH-35): + для не-self репо → `(True, "Staging gate N/A …")`. +- `src/stage_engine.py` → `_handle_qg_failure_rollbacks`: ветка + `agent=="deployer" and qg=="check_staging_status"` → откат на `development`. +- `src/agents/launcher.py` → `_monitor_agent`: ветка «no changes to commit» (строка + ~583) **уже** просто логирует и идёт в `_try_advance_stage` (НЕ откатывает). + +## Рассмотренные направления (BRD §6) + +- **(а) Починить sandbox-инфру** — добавить bot-токены SANDBOX, чтобы C9a/C9b + проходили честно (10/10). + - *Минусы:* хрупко (зависит от членства ботов в Plane-проекте, поддерживается + руками вне кода); не предотвращает структурно будущие инфра-only FAIL; + автономный self-deploy-таск не может надёжно выполнить Plane-admin действия сам. + Не закрывает Причину №1 на уровне инварианта. +- **(б) Отвязать вердикт от заведомо инфраструктурных проверок** — классифицировать + проверки suite и сделать вердикт толерантным к инфра-FAIL, сохранив fail-closed + для реальных проверок. + - *Плюсы:* структурно, юнит-тестируемо (чистая вердикт-логика), управляемо + (kill-switch), наблюдаемо (FR-7); сохраняет страховку (FR-4) по построению. + +## Решение + +Выбран механизм **(б)** как основной, с явной фиксацией инварианта по Причине №2. +Направление (а) переведено в **необязательное hardening** (см. `07-infra-requirements.md`): +с (б) оно перестаёт быть блокером. + +### 1. Классификация проверок + толерантный вердикт (Причина №1, FR-2/FR-4) + +Новый **leaf-модуль `src/staging_verdict.py`** — чистая логика, без I/O, контракт +**never-raise**, только stdlib (импортируем и из orchestrator, и из +`staging_check.py`, который уже импортирует `src.*` внутри контейнера — паттерн B6/ORCH-048): + +``` +REAL = "real" # реальная pipeline-проверка +SANDBOX_INFRA = "sandbox_infra" # заведомо зависит от sandbox-инфры + +# Узкий allowlist известных инфра-проверок (по префиксу метки): +SANDBOX_INFRA_CHECKS = frozenset({"C9a", "C9b"}) + +def classify_check(label: str) -> str: + """SANDBOX_INFRA если метка начинается с известного инфра-префикса, иначе REAL. + Never-raise: на любом непонятном вводе → REAL (консервативно, fail-closed).""" + +def compute_staging_verdict(items, infra_tolerant: bool) -> StagingVerdict: + """items: список (label, passed: bool, category: str). + real_failed = [REAL-проверки с passed=False] + infra_failed = [SANDBOX_INFRA-проверки с passed=False] + - real_failed непусто -> FAILED, exit 1 (страховка) + - infra_failed непусто и infra_tolerant -> SUCCESS, exit 0 (waived) + - infra_failed непусто и НЕ infra_tolerant -> FAILED, exit 1 (legacy strict) + - иначе -> SUCCESS, exit 0 + Never-raise: на битом вводе → консервативный FAILED.""" +``` + +`StagingVerdict` несёт `status` (`"SUCCESS"|"FAILED"`), `exit_code` (`0|1`), +`waived` (список заваиверенных меток) и `summary` (человекочитаемая строка). + +**Ключевой инвариант страховки (FR-4):** любая упавшая REAL-проверка ⇒ exit 1 ⇒ +FAILED ⇒ откат. В частности C7 (создать issue) и C8 (триггер `/webhook/plane`) — +REAL. Waiver применяется к C9a/C9b **только** когда все REAL-проверки (включая +C7/C8) зелёные. Вход в конвейер по-прежнему валидируется C7/C8; C9a/C9b проверяют +лишь downstream-артефакты, которым нужна sandbox-инфра. Так blast-radius waiver'а +сведён к двум именованным проверкам. + +### 2. Правки `scripts/staging_check.py` + +- `Results.add(label, passed, detail="", category=None)` — при `category is None` + авто-классификация через `staging_verdict.classify_check(label)`; хранит категорию + в элементе. +- `Results.summary()` печатает разбивку по категориям (REAL / SANDBOX_INFRA). +- `main()`: + - резолвит флаг толерантности `_resolve_tolerance()` (см. ниже); + - `verdict = compute_staging_verdict(results.items, infra_tolerant)`; + - при `verdict.waived` печатает явную строку + `INFRA-WAIVED: (known sandbox-infra; real checks green)` (FR-7); + - `sys.exit(verdict.exit_code)`. +- `_resolve_tolerance()`: читает `settings.staging_infra_tolerance_enabled` (через + `from src.config import settings` — тот же паттерн, что B6). На ошибке импорта → + **strict (False)** (fail-safe: не вайвить при нечитаемом конфиге) + warning. + Опциональный CLI-флаг `--strict` принудительно выключает толерантность для ручных + «честных» прогонов. + +Прежние режимы (`--mode stub|full-real`) и проверки A/B/C7/C8 — без изменений. +«Всегда 0» исключено: упавшая REAL-проверка всегда даёт exit 1 (TRZ §7). + +### 3. Kill-switch (FR-6, AC-7) + +`src/config.py`: +```python +# ORCH-061: толерантность staging-вердикта к заведомо инфраструктурным FAIL +# (C9a/C9b) в sandbox. True -> упавшие ТОЛЬКО sandbox-инфра проверки вайверятся +# (real-проверки fail-closed). False -> 1:1 прежнее строгое поведение (любой FAIL +# -> staging_status FAILED -> откат). Env ORCH_STAGING_INFRA_TOLERANCE_ENABLED. +staging_infra_tolerance_enabled: bool = True +``` + +Дефолт **True** (как `merge_gate_enabled` / `image_freshness_enabled` / +`self_deploy_enabled`): инвариант страховки (FR-4) держится независимо от флага — +реальные провалы всё равно fail-closed; флаг существует, чтобы мгновенно вернуть +legacy-строгость без передеплоя кода. Флаг живёт в `.env.staging` контейнера +(`ORCH_` prefix), поэтому достижим скриптом внутри `orchestrator-staging`. +`False` → suite строгий → 1:1 поведение до ORCH-061 (AC-7). + +### 4. Что НЕ меняется (контракты, AC-8) + +- `check_staging_status` / `_parse_staging_status` — **без изменений**: читают + `staging_status:` (только YAML, `SUCCESS|FAILED`). Толерантность реализована + ДО артефакта (в exit-code suite → вердикт deployer), внутри существующего пути + staging-вердикта, не отдельной стадией (TRZ §6). +- **Новый QG-чек НЕ добавляется** → реестр `QG_CHECKS` и снапшот-тест + (`tests/test_qg_registry_snapshot.py`) неизменны (AC-8 / TC-11). +- `STAGE_TRANSITIONS`, `get_previous_stage`, exit-code хука деплоя (0/1/2), + `map_exit_code_to_status`, `check_deploy_status`, БАГ-8 — без изменений. +- Условность self-hosting (AC-6): `staging_check.py` канонически бежит только для + `orchestrator`; `check_staging_status` для не-self репо остаётся + `(True, "Staging gate N/A …")`. Поведение прочих репо байт-в-байт неизменно. + +### 5. Инвариант «no-changes на action-стадии» (Причина №2, FR-3/AC-4) + +`launcher._monitor_agent` **уже** не откатывает на «no changes to commit» (просто +логирует и идёт в `_try_advance_stage`; продвижение определяется гейтом). ORCH-061: +- **Фиксируем инвариант** как покрытый тестами контракт: на `deploy-staging`/`deploy` + для self-deploy продвижение определяется exit0 + гейт-вердиктом, НИКОГДА наличием + коммита (TC-06). +- **Наблюдаемость (FR-7/AC-11):** в ветке «no changes» логировать явную строку, + отличающую action-стадию (ожидаемо: артефакт-вердикт, не обязательно код) от + code-стадии. Резолв стадии задачи по `(repo, branch)`; при + `stage ∈ {deploy-staging, deploy}` и `self_deploy.self_deploy_applies(repo)` → + `staging/deploy: no code changes (expected on action stage)`. +- **Regression-guard (TC-07):** на `development` (code-стадия) поведение «no changes» + неизменно — изменение FR-3 не протекает на не-action стадию. + +Изменение минимальное (self-hosting safety, AC-12): не трогает прод-контейнер 8500, +сборки/recreate — только staging (8501). + +## Затронутые файлы (для developer) + +| Файл | Изменение | +|------|-----------| +| `src/staging_verdict.py` | **новый** leaf-модуль: `classify_check`, `compute_staging_verdict`, `StagingVerdict` (pure, never-raise). | +| `scripts/staging_check.py` | категории в `Results`, вердикт через `staging_verdict`, INFRA-WAIVED-лог, `--strict`. | +| `src/config.py` | флаг `staging_infra_tolerance_enabled` (env `ORCH_STAGING_INFRA_TOLERANCE_ENABLED`). | +| `src/agents/launcher.py` | observability-лог action-stage no-changes (без смены логики продвижения). | +| `.openclaw/agents/deployer.md` | уточнение: exit0 может включать «infra-waived»; контракт `staging_status:` SUCCESS\|FAILED неизменен. | +| `docs/operations/STAGING_CHECK.md` | поведение C9a/C9b, флаг, INFRA-WAIVED, `--strict`. | +| `docs/architecture/README.md` | пометка ORCH-061 в разделе staging-гейта (уже внесена архитектором). | +| `CHANGELOG.md` | запись ORCH-061. | +| `tests/` | TC-01…TC-14 (см. `04-test-plan.yaml`). | + +## Последствия + +**Плюсы** +- Петля устранена структурно: ложный инфра-FAIL → SUCCESS (waived) → нет отката (G1/G2). +- Страховка цела: любая реальная pipeline-проверка fail-closed → FAILED → откат (G4/FR-4). +- Чистая вердикт-логика юнит-тестируема без live staging/docker (NFR-тестируемость). +- Контракты гейтов/стадий/вердиктов/реестра не тронуты (AC-8); схема БД не меняется (AC-9). +- Мгновенный откат к legacy через kill-switch (AC-7). +- Разблокирует автономный self-deploy (ORCH-54). + +**Минусы / ограничения** +- C9a/C9b теперь могут заваиверить **реальный** даунстрим-регресс именно в создании + ветки / постановке analyst-job (узкий риск). Митигировано: waiver только когда C7/C8 + и все прочие REAL зелёные; allowlist жёстко = {C9a, C9b}; INFRA-WAIVED логируется и + виден оператору. См. `10-tech-risks.md` (R-1). +- Толерантность скрывает «нездоровье sandbox» как зелёное-с-допущением; отличимо + только по INFRA-WAIVED-логу/комментарию (наблюдаемость обязательна, FR-7). +- Honest 10/10 в sandbox (направление а) остаётся желательным hardening, но не блокером. + +## Альтернативы (отклонены) + +- **Только (а) — починить sandbox-инфру:** хрупко, не структурно, вне автономной + досягаемости таска. Оставлено как опциональное hardening. +- **«Зелёный по умолчанию» при недоступности проверок:** запрещён FR-4 (fail-closed). +- **Новый QG-чек `check_staging_infra_tolerant`:** избыточно — менял бы реестр + `QG_CHECKS` и снапшот; толерантность лучше живёт в suite/вердикте до артефакта. +- **Толерантность внутри `check_staging_status` через структурный артефакт:** + потребовал бы сменить контракт `15-staging-log.md` и научить deployer писать + per-check категории — больше движущихся частей; отклонено в пользу решения в suite. diff --git a/docs/work-items/ORCH-061/07-infra-requirements.md b/docs/work-items/ORCH-061/07-infra-requirements.md new file mode 100644 index 0000000..be702cc --- /dev/null +++ b/docs/work-items/ORCH-061/07-infra-requirements.md @@ -0,0 +1,37 @@ +# 07 — Требования к инфраструктуре: ORCH-061 + +Work Item: **ORCH-061** · Репо: `orchestrator` + +Топология/контейнеры/порты **не меняются** (TRZ §3, §9). Self-hosting-безопасность +сохранена: прод-контейнер `orchestrator` (8500) не перезапускается/не роняется в +рамках задачи; любые сборки/recreate — только staging (8501). См. +`docs/operations/INFRA.md`. + +## IR-1 — Конфиг-флаг (kill-switch) +Новый флаг `staging_infra_tolerance_enabled` (env +`ORCH_STAGING_INFRA_TOLERANCE_ENABLED`, дефолт `true`). + +- Должен присутствовать в окружении контейнера **`orchestrator-staging`** + (`.env.staging`), т.к. `scripts/staging_check.py` читает его через + `src.config.settings` при каноническом запуске `docker exec` внутри стенда. +- Для прод-инстанса (`.env`) флаг безвреден (на прод-пути staging-suite не + исполняется), но рекомендуется держать значения консистентными. +- `false` → мгновенный возврат к строгому (legacy) поведению без передеплоя кода. +- Канон секретов/env: значения в `.env`/`.env.staging` на хосте, в гит НЕ + коммитятся; задокументировать ключ в `.env.example` (канон ORCH-9). + +## IR-2 — Опциональное hardening sandbox (направление «а», НЕ блокер) +Первопричина ложных C9a/C9b — bot-аккаунты агентов (`ORCH_PLANE_BOT_*`) не добавлены +членами Plane-проекта **SANDBOX** (`8c5a3025-…`), созданного после провижининга +ботов. С выбранным механизмом (б) это перестаёт блокировать конвейер, но честный +10/10 в sandbox желателен: + +- Добавить bot-аккаунты агентов членами SANDBOX-проекта в Plane (даст честный + C9b: коммент analyst'а перестанет получать 403; и устранит инфра-причину C9a/C9b). +- Действие — ручное (Plane-admin), вне автоматической досягаемости таска; выполняется + оператором при возможности. После него C9a/C9b проходят честно и waiver не нужен. +- Это hardening, а не требование приёмки ORCH-061 (приёмка — на механизме «б»). + +## IR-3 — Без новой инфраструктуры +Новые сервисы/порты/тома/сетевые правила/cron — **не требуются**. Никаких +изменений в `docker-compose.yml`, образах, реестре проектов. diff --git a/docs/work-items/ORCH-061/08-data-requirements.md b/docs/work-items/ORCH-061/08-data-requirements.md new file mode 100644 index 0000000..0c67ced --- /dev/null +++ b/docs/work-items/ORCH-061/08-data-requirements.md @@ -0,0 +1,20 @@ +# 08 — Требования к данным / схеме БД: ORCH-061 + +Work Item: **ORCH-061** · Репо: `orchestrator` + +## DR-1 — Схема БД не меняется (AC-9) +Никаких миграций. Таблицы `events`, `tasks`, `agent_runs`, `jobs` — без изменений +колонок/индексов/таблиц. + +## DR-2 — Никакого нового персистентного состояния +Решение (ADR-001) — чистая вердикт-логика (`src/staging_verdict.py`) + конфиг-флаг + +правка exit-code suite. Состояние конвейера не вводится: +- толерантность вычисляется на лету при прогоне `staging_check.py`; +- restart-safe-состояние не требуется (вердикт фиксируется в существующем артефакте + `15-staging-log.md` через прежний контракт `staging_status: SUCCESS|FAILED`). + +## DR-3 — Артефакт-контракт неизменен +`15-staging-log.md` по-прежнему несёт frontmatter `staging_status: SUCCESS|FAILED` +(только YAML). `14-deploy-log.md` (`deploy_status:`) — без изменений. Гейты читают +ТОЛЬКО frontmatter. Толерантность реализована ДО записи артефакта (на уровне +exit-code suite → вердикт deployer), поэтому формат и парсинг артефактов не трогаются. diff --git a/docs/work-items/ORCH-061/10-tech-risks.md b/docs/work-items/ORCH-061/10-tech-risks.md new file mode 100644 index 0000000..f13db54 --- /dev/null +++ b/docs/work-items/ORCH-061/10-tech-risks.md @@ -0,0 +1,23 @@ +# 10 — Технические риски: ORCH-061 + +Work Item: **ORCH-061** · Репо: `orchestrator` (self-hosting) + +| # | Риск | Вероятн. | Влияние | Митигация | +|---|------|----------|---------|-----------| +| **R-1** | Waiver C9a/C9b маскирует **реальный** регресс именно в создании ветки / постановке analyst-job (ложно-зелёный staging). | Низкая | Высокое | Allowlist жёстко `{C9a, C9b}`; waiver применяется ТОЛЬКО когда ВСЕ REAL-проверки зелёные, включая C7 (создать issue) и C8 (триггер `/webhook/plane`) — вход в конвейер всегда валидируется реально. `INFRA-WAIVED`-строка в логе/комменте делает допущение видимым (FR-7). Honest 10/10 (IR-2) убирает риск совсем. | +| **R-2** | Ослабление страховки: реальный pipeline-FAIL пройдёт как SUCCESS. | Низкая | Критич. | Инвариант `compute_staging_verdict`: любая упавшая REAL → exit1 → FAILED → откат (FR-4/AC-3/TC-05). Покрыто юнит-тестом отдельным кейсом. | +| **R-3** | Флаг не достигает скрипта (читается не из того env) → толерантность «молча» не работает или, наоборот, не выключается. | Средняя | Среднее | Скрипт читает `settings.staging_infra_tolerance_enabled` через `from src.config import settings` — тот же канал, что B6/ORCH-048 (внутри `orchestrator-staging`, env `.env.staging`). На ошибке импорта — fail-safe в strict (False) + warning. Документировать ключ в `.env.staging`/`.env.example` (IR-1). Тест kill-switch (TC-09). | +| **R-4** | Классификатор ошибочно пометит REAL-проверку как SANDBOX_INFRA (расширение allowlist в будущем). | Низкая | Высокое | `classify_check` — узкий префиксный allowlist; добавление новой инфра-метки требует осознанного PR + теста (TC-03). По умолчанию неизвестная метка → REAL (консервативно). | +| **R-5** | Регресс совместимости: изменение exit-code suite ломает другие потребители (deploy-хук, ручные прогоны). | Низкая | Среднее | Exit-code семантика сохранена для honest-прогонов (всё PASS → 0; реальный FAIL → 1). Меняется лишь трактовка «только инфра-FAIL» (теперь 0 при толерантности). Deployer-маппинг exit0→SUCCESS/≠0→FAILED не меняется; deployer.md уточняется. `--strict` даёт ручной honest-режим. | +| **R-6** | never-raise нарушен: исключение из `staging_verdict`/классификатора. | Низкая | Среднее | `src/staging_verdict.py` — pure, без I/O; контракт never-raise (на битом вводе → консервативный FAILED). Логика вне пути `advance_stage` (исполняется в subprocess suite), поэтому в конвейер исключение структурно не попадает (AC-10). | +| **R-7** | FR-3: правка no-changes протекает на code-стадию (`development`) и маскирует «developer ничего не сделал». | Низкая | Среднее | Observability-строка ограничена `stage ∈ {deploy-staging, deploy}` и `self_deploy_applies(repo)`; логика продвижения launcher не меняется. Regression-guard TC-07. | +| **R-8** | Self-hosting: правки случайно затронут прод 8500 / не-self репо. | Низкая | Критич. | Изменения только на self-deploy-пути и в suite (бежит лишь для `orchestrator`-staging). `check_staging_status` для не-self репо неизменно `(True, N/A)` (AC-6/TC-08). Сборки/recreate — только 8501. Прод 8500 не трогается (AC-12). | + +## Контрактные инварианты (не нарушать) +- `STAGE_TRANSITIONS`, `get_previous_stage` — без изменений. +- Реестр `QG_CHECKS` — без изменений; новый QG-чек НЕ вводится (снапшот-тест зелёный, TC-11). +- Frontmatter `staging_status:` / `deploy_status:` — только YAML, `SUCCESS|FAILED`. +- Exit-code хука деплоя (0/1/2) и `map_exit_code_to_status` — без изменений. +- БАГ-8 (`deploy → development`) и ORCH-35 (`deploy-staging → development`) для + **реальных** провалов — сохранены. +- Схема БД — без миграций. From 90704899688689d6069d367157bd9f8d19467c1d Mon Sep 17 00:00:00 2001 From: claude-bot Date: Sun, 7 Jun 2026 12:39:00 +0000 Subject: [PATCH 4/9] fix(staging): tolerate sandbox-infra-only FAILs (C9a/C9b) in deploy-staging verdict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The self-hosting orchestrator looped on deploy-staging -> development because scripts/staging_check.py exited 1 on ANY failed check, so two infra-only checks (C9a sandbox branch / C9b analyst-job — caused by SANDBOX bot accounts not being members of the sandbox Plane project, NOT a pipeline regress) forced staging_status: FAILED -> rollback -> loop, burning developer retries and tokens. Direction (б) per ADR-001: classify staging checks as REAL (all pipeline checks, fail-closed) vs SANDBOX_INFRA (narrow allowlist {C9a, C9b}, waivable). New leaf module src/staging_verdict.py (stdlib-only, never-raise): classify_check + compute_staging_verdict fold per-check results into a tolerant-but-fail-closed verdict — any REAL failure -> FAILED/exit1 (safety net holds under any flag); only C9a/C9b failed & tolerant -> SUCCESS/exit0 with waived list; only infra & strict -> FAILED/exit1; any internal error -> FAILED/exit1 (never a false green). staging_check.py now auto-classifies each check (public 3-tuple _items shape kept as an ORCH-048 b6 regression guard), exposes categorized_items(), prints INFRA-WAIVED/VERDICT lines, and exits via the verdict; new --strict flag forces legacy strictness per-run. Kill-switch ORCH_STAGING_INFRA_TOLERANCE_ENABLED (default true) restores legacy strict mode globally. launcher gains action_stage_no_changes_note so "no changes to commit" on action stages is logged as expected, not treated as under-delivery. Contracts unchanged: STAGE_TRANSITIONS, QG_CHECKS registry, staging_status:/ deploy_status: frontmatter, hook exit-code (0/1/2), check_staging_status; no DB migration. Docs: README, STAGING_CHECK.md, deployer.md, .env.example, CHANGELOG. Refs: ORCH-061 Co-Authored-By: Claude Opus 4.7 --- .env.example | 10 ++ .openclaw/agents/deployer.md | 15 ++- CHANGELOG.md | 1 + docs/architecture/README.md | 2 +- docs/operations/STAGING_CHECK.md | 54 +++++++++- scripts/staging_check.py | 116 ++++++++++++++++++++- src/agents/launcher.py | 43 ++++++++ src/config.py | 16 +++ src/staging_verdict.py | 173 +++++++++++++++++++++++++++++++ tests/test_config.py | 23 ++++ tests/test_launcher.py | 45 ++++++++ tests/test_qg.py | 21 ++++ tests/test_qg_checks.py | 97 +++++++++++++++++ tests/test_stage_engine.py | 155 +++++++++++++++++++++++++++ tests/test_staging_check_b6.py | 67 ++++++++++++ 15 files changed, 831 insertions(+), 7 deletions(-) create mode 100644 src/staging_verdict.py diff --git a/.env.example b/.env.example index 40dd85f..eb9fbfa 100644 --- a/.env.example +++ b/.env.example @@ -85,6 +85,16 @@ ORCH_DEPLOY_PROD_PREV_IMAGE_FILE=.deploy-prev-image-prod ORCH_IMAGE_FRESHNESS_ENABLED=true ORCH_IMAGE_FRESHNESS_REPOS= +# ORCH-061: staging-verdict tolerance to sandbox-infra-only FAILs. The self-hosting +# orchestrator looped on deploy-staging because staging_check.py exited 1 on ANY FAIL, +# so two infra-only checks (C9a sandbox branch / C9b analyst-job — caused by SANDBOX +# bot accounts not being members of the sandbox Plane project, NOT a pipeline regress) +# forced staging_status: FAILED -> rollback -> loop. With this ON, C9a/C9b are WAIVED +# to SUCCESS when every REAL check is green; any REAL failure still fails closed. +# true (default) -> tolerant; false -> legacy strict (1:1 pre-ORCH-061, any FAIL rolls back). +# Lives in .env.staging (the staging instance). CLI --strict overrides this per-run. +ORCH_STAGING_INFRA_TOLERANCE_ENABLED=true + # 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, # fixing tasks that got stuck on a dropped event (502 on rebuild, no Plane/Gitea diff --git a/.openclaw/agents/deployer.md b/.openclaw/agents/deployer.md index 6126307..3e74082 100644 --- a/.openclaw/agents/deployer.md +++ b/.openclaw/agents/deployer.md @@ -37,8 +37,19 @@ On stage `deploy-staging` your job is to run the staging test suite and write a not exist. Details: `docs/operations/STAGING_CHECK.md`. 2. Check the exit code: - - Exit code **0** = all tests PASS → `staging_status: SUCCESS` - - Exit code **non-zero** = tests FAILED → `staging_status: FAILED` + - Exit code **0** = advance → `staging_status: SUCCESS` + - Exit code **non-zero** = rollback → `staging_status: FAILED` + + > **ORCH-061**: exit 0 may now include *waived* sandbox-infra failures. The two + > infra-only checks **C9a/C9b** (sandbox branch / analyst-job, which depend on + > SANDBOX bot accounts being project members — not on the pipeline) are tolerated + > when every REAL check is green; the script prints an `INFRA-WAIVED:` line and a + > `VERDICT:` line, and still exits 0. Any REAL check failing still yields exit 1 + > (fail-closed). If you see `INFRA-WAIVED:` in the output, copy that line into the + > `15-staging-log.md` body for observability. The exit-code → `staging_status` + > mapping above is unchanged: trust the exit code, do NOT re-judge waived checks. + > Kill-switch: `ORCH_STAGING_INFRA_TOLERANCE_ENABLED=false` (or `--strict`) restores + > legacy strictness. Details: `docs/operations/STAGING_CHECK.md`. 3. Write the verdict to `docs/work-items//15-staging-log.md` with YAML frontmatter: ```markdown diff --git a/CHANGELOG.md b/CHANGELOG.md index a3670e8..655c084 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ - Цепочка стадий: `... testing → deploy-staging → deploy → done` (была без `deploy-staging`). ### Fixed +- **`deploy-staging` больше не зацикливается на infra-only FAIL песочницы (C9a/C9b)** (ORCH-061): self-hosting `orchestrator` крутился в петле `deploy-staging → development` — `scripts/staging_check.py` давал `exit 1` при ЛЮБОМ упавшем чеке, поэтому две чисто инфраструктурные проверки **C9a** (ветка не появилась в `orchestrator-sandbox`) и **C9b** (job аналитика не встал в очередь staging) — вызванные тем, что SANDBOX-бот-аккаунты не состоят в sandbox-проекте Plane (шаги 6+ конвейера в песочнице недостижимы, это НЕ регресс конвейера) — приводили к `staging_status: FAILED` → откат → цикл (выжигание developer-ретраев, токенов, паразитная нагрузка общего инстанса). Решение (Direction «б», ADR-001): чеки классифицируются на `REAL` (все проверки конвейера A*/B*/C7/C8 — fail-closed) и `SANDBOX_INFRA` (строго allowlist `{C9a, C9b}` — waivable). Новый leaf-модуль `src/staging_verdict.py` (stdlib-only, контракт «never raise», по образцу `merge_gate`/`image_freshness`): `classify_check(label)` (allowlist по ведущему токену, всё неизвестное/малформенное → `REAL` fail-closed) и `compute_staging_verdict(items, infra_tolerant) -> StagingVerdict`: любой REAL-FAIL → `FAILED`/exit 1 (страховка при ЛЮБОМ значении флага); упали ТОЛЬКО C9a/C9b и толерантность включена → `SUCCESS`/exit 0 + упавшие метки в `waived` (наблюдаемость); только C9a/C9b и толерантность выключена → `FAILED`/exit 1 (legacy-строгий); любая внутренняя ошибка вердикта → `FAILED`/exit 1 (никогда не ложный green). `scripts/staging_check.py`: `Results` авто-классифицирует каждый чек (публичная 3-tuple форма `_items` сохранена — регрессия-гард ORCH-048 b6), `categorized_items()` отдаёт категорию, `summary()` печатает разбивку REAL/SANDBOX_INFRA; `main()` сворачивает прогон через `_verdict(...)`, печатает строки `INFRA-WAIVED:`/`VERDICT:` и делает `sys.exit(verdict.exit_code)`; новый флаг `--strict` форсит строгий режим для одного запуска. Глобальный kill-switch `ORCH_STAGING_INFRA_TOLERANCE_ENABLED` (`Settings.staging_infra_tolerance_enabled`, default `true`; `false` → строгий 1:1 до ORCH-061), живёт в `.env.staging`; `--strict` имеет приоритет над env. Наблюдаемость на стороне конвейера: `src/agents/launcher.py` получил `action_stage_no_changes_note(stage, repo)` — на action-стадиях (`deploy-staging`/`deploy`) self-hosting-репо «нет изменений для коммита» логируется как ожидаемое, а не трактуется как недопоставка. Контракты НЕ менялись: `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, frontmatter `staging_status: SUCCESS|FAILED` / `deploy_status:` (толерантность применяется в скрипте ДО записи артефакта деплоером), exit-code-контракт хука (0/1/2), `check_staging_status`/`_parse_staging_status`; схема БД — без миграций. ADR `docs/work-items/ORCH-061/06-adr/ADR-001-staging-infra-tolerance.md`. Документация: `docs/architecture/README.md`, `docs/operations/STAGING_CHECK.md`, `.openclaw/agents/deployer.md`. Тесты: `tests/test_staging_check_b6.py`, `tests/test_qg_checks.py`, `tests/test_config.py`, `tests/test_launcher.py`, `tests/test_qg.py`, `tests/test_stage_engine.py::TestStagingInfraTolerance`. - **Reconciler (F-1) больше не разблокирует escalated / Blocked / Needs-Input задачи** (ORCH-060): sweeper потерянных webhook (ORCH-053) не отличал «застряла из-за потерянного события» от «исчерпала лимит developer-ретраев и ждёт человека» — если CI зелёный, а reviewer слал REQUEST_CHANGES до `MAX_DEVELOPER_RETRIES`, каждый тик F-1 видел зелёный `check_ci_green` и доигрывал `development → review` → reviewer снова REQUEST_CHANGES → откат (стадия не меняется, escalated в `gitea.py` лишь шлёт `notify_error`) → следующий тик снова разблокировал. Бесконечная петля (инцидент ET-013: 10 разблокировок за ночь, лишние запуски агентов/токены, спам в Telegram, паразитная нагрузка общего self-hosting-инстанса). В `Reconciler._reconcile_gate_task` (`src/reconciler.py`) ПОСЛЕ существующих гардов (`analysis` carve-out, нет гейта, активный job, grace) и ДО пред-оценки гейта добавлены два пред-гарда с ранним `return` (молчаливый skip — без `advance`, без инкремента `unblocked_total`, без нотификаций): **Guard 1 (escalated, детерминированный, без сети, проверяется первым)** — `developer_retry_count(task_id) >= MAX_DEVELOPER_RETRIES`; приватный `stage_engine._developer_retry_count` повышен до публичного `developer_retry_count` (единый источник истины по подсчёту ретраев `agent_runs`, приватное имя сохранено как алиас), граница берётся из `stage_engine.MAX_DEVELOPER_RETRIES` (не хардкод `3`). **Guard 2 (явный человеческий Plane-статус, Вариант A — без миграции БД)** — новый never-raise хелпер `plane_sync.fetch_issue_state(issue_id, project_id) -> str|None` (тот же endpoint/headers, что `fetch_issue_sequence_id`) + `Reconciler._is_blocked_or_needs_input(task)`: резолв проекта (`projects.get_project_by_repo`) → `get_project_states(pid)` → сверка текущего state issue с `blocked`/`needs_input`; любая ошибка/`None`/нерезолвленный проект → консервативный skip (`True`: не-разблокировать безопаснее). F-2 по существу не менялся: Blocked/Needs Input не входят в опрашиваемый набор `{in_progress, approved, rejected}` → не доигрываются (зафиксировано регресс-тестом). Новый под-флаг `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED` (true) гасит ТОЛЬКО сетевой Guard 2 (escape hatch при Plane-outage); Guard 1 всегда активен. Схема БД, `STAGE_TRANSITIONS`, `QG_CHECKS`, never-raise на единицу работы, `analysis` carve-out и kill-switch'и (`reconcile_enabled`/`reconcile_plane_enabled`) не менялись. ADR `docs/work-items/ORCH-060/06-adr/ADR-001-reconciler-skip-escalated.md`. Тесты: `tests/test_reconciler.py` (TC-01…TC-11 + регресс ORCH-053). - **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, рекурсивное удаление `/.deploy-state-//`), вызывается в ветке БАГ-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`. diff --git a/docs/architecture/README.md b/docs/architecture/README.md index c578e55..c8b2de5 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -211,4 +211,4 @@ never-raise на единицу работы; тишина при синхрон Схема БД, потоки данных, resilience-слой, детали Dockerfile — [internals.md](internals.md). --- -*Актуально на 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-060 (F-1 skip escalated/Blocked/Needs-Input, `docs/work-items/ORCH-060/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-060 (Guard 1 `developer_retry_count>=MAX_DEVELOPER_RETRIES` + Guard 2 `plane_sync.fetch_issue_state` Blocked/Needs-Input, флаг `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED`); 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); ORCH-061 (толерантность staging-вердикта к инфра-FAIL C9a/C9b, adr-0009, `docs/work-items/ORCH-061/06-adr/ADR-001`) — design, ветка feature/ORCH-061 (обновлять также при изменении src/staging_verdict.py, scripts/staging_check.py, флаг staging_infra_tolerance_enabled).* +*Актуально на 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-060 (F-1 skip escalated/Blocked/Needs-Input, `docs/work-items/ORCH-060/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-060 (Guard 1 `developer_retry_count>=MAX_DEVELOPER_RETRIES` + Guard 2 `plane_sync.fetch_issue_state` Blocked/Needs-Input, флаг `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED`); 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); ORCH-061 (толерантность staging-вердикта к инфра-FAIL C9a/C9b, adr-0009, `docs/work-items/ORCH-061/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-061 (обновлять также при изменении src/staging_verdict.py, scripts/staging_check.py, флаг staging_infra_tolerance_enabled).* diff --git a/docs/operations/STAGING_CHECK.md b/docs/operations/STAGING_CHECK.md index f3e275e..fd4920e 100644 --- a/docs/operations/STAGING_CHECK.md +++ b/docs/operations/STAGING_CHECK.md @@ -12,7 +12,9 @@ | B | ACCESS | Plane sandbox (R), Gitea sandbox (R+push), реестр проектов | | C | E2E | Создать задачу → триггер конвейера → ветка + коммент → cleanup | -Exit code: **0** = все PASS, **non-zero** = есть FAIL. +Exit code: **0** = advance (все REAL-проверки PASS), **1** = rollback (есть REAL-FAIL). +С ORCH-061 exit 0 может включать *waived* sandbox-infra FAIL (C9a/C9b) — см. +[«Толерантность к sandbox-infra (ORCH-061)»](#толерантность-к-sandbox-infra-orch-061). --- @@ -85,6 +87,56 @@ B6 «Registry: sandbox present, prod ET/ORCH absent» подтверждает --- +## Толерантность к sandbox-infra (ORCH-061) + +**Проблема.** Self-hosting `orchestrator` зацикливался на `deploy-staging → development`: +прежде скрипт давал exit 1 при **любом** FAIL, поэтому две чисто инфраструктурные +проверки — **C9a** (ветка не появилась в `orchestrator-sandbox`) и **C9b** (job +аналитика не встал в очередь staging) — приводили к `staging_status: FAILED` → +откат → цикл. Корень: SANDBOX-бот-аккаунты не состоят в sandbox-проекте Plane, +поэтому шаги 6+ конвейера в песочнице недостижимы. Это **не** регресс конвейера. + +**Решение.** Проверки классифицируются на две категории (`src/staging_verdict.py`): + +| Категория | Что входит | Поведение | +|-----------|-----------|-----------| +| `REAL` | все проверки конвейера (A*, B*, C7, C8) | **fail-closed** — любой FAIL = rollback | +| `SANDBOX_INFRA` | строго allowlist `{C9a, C9b}` | **waivable** — FAIL терпится, если все REAL зелёные | + +Вердикт сворачивается в `compute_staging_verdict(items, infra_tolerant)`: + +- любой REAL-FAIL → `FAILED` / exit 1 (страховка сохраняется при ЛЮБОМ значении флага); +- упали **только** C9a/C9b и толерантность включена → `SUCCESS` / exit 0, + упавшие метки попадают в `waived` (наблюдаемость, печатается строкой `INFRA-WAIVED:`); +- упали только C9a/C9b, толерантность выключена → `FAILED` / exit 1 (legacy-строгий); +- любая внутренняя ошибка вердикта → `FAILED` / exit 1 (никогда не ложный green). + +Blast-radius waiver-а ровно две allowlist-метки; всё неизвестное классифицируется +как `REAL` (fail-closed). + +### Kill-switch и `--strict` + +| Управление | Эффект | +|-----------|--------| +| env `ORCH_STAGING_INFRA_TOLERANCE_ENABLED` (default `true`) | глобальный флаг; `false` → строгий режим (1:1 до ORCH-061) | +| CLI `--strict` | форсит строгий режим для одного запуска, игнорируя env | + +Флаг живёт в `.env.staging` (staging-инстанс). `--strict` имеет приоритет над env. + +### Что печатает скрипт + +В конце прогона `summary()` показывает разбивку REAL/SANDBOX_INFRA, затем: + +``` +INFRA-WAIVED: C9a Branch appears in orchestrator-sandbox; C9b Analyst job enqueued ... +VERDICT: SUCCESS (infra-waived): ['C9a …', 'C9b …'] are known sandbox-infra checks; all real checks green +``` + +Контракт `staging_status: SUCCESS|FAILED` во frontmatter **не меняется** — +толерантность применяется в скрипте ДО записи артефакта деплоером. + +--- + ## Режимы (`--mode`) | Режим | Описание | Скорость | diff --git a/scripts/staging_check.py b/scripts/staging_check.py index 75ba892..6cbac67 100644 --- a/scripts/staging_check.py +++ b/scripts/staging_check.py @@ -51,6 +51,46 @@ import datetime import urllib.request import urllib.error import urllib.parse +from collections import namedtuple + +# --------------------------------------------------------------------------- +# ORCH-061: pure staging-verdict logic (classification + infra-tolerant verdict). +# Imported from src.staging_verdict — a stdlib-only leaf, safe to import inside +# the orchestrator-staging container (PYTHONPATH=/app, pattern B6 / ORCH-048). +# Guarded so the suite still runs (in strict mode) if src is somehow unimportable +# from a host invocation; the fallback NEVER yields a silent green (fail-closed). +# --------------------------------------------------------------------------- +try: + from src.staging_verdict import ( # type: ignore + classify_check as _classify_check, + compute_staging_verdict as _compute_staging_verdict, + REAL as _REAL, + SANDBOX_INFRA as _SANDBOX_INFRA, + ) +except Exception: # pragma: no cover - exercised only on a broken host import + _classify_check = None + _compute_staging_verdict = None + _REAL = "real" + _SANDBOX_INFRA = "sandbox_infra" + +_FallbackVerdict = namedtuple("StagingVerdict", "status exit_code waived summary") + + +def _classify(label: str) -> str: + """Classify a check label via staging_verdict; fail-closed to REAL if absent.""" + if _classify_check is not None: + return _classify_check(label) + return _REAL + + +def _verdict(items, infra_tolerant: bool): + """Compute the suite verdict via staging_verdict; strict fail-closed fallback.""" + if _compute_staging_verdict is not None: + return _compute_staging_verdict(items, infra_tolerant) + failed = [lbl for (lbl, ok, _cat) in items if not ok] + if failed: + return _FallbackVerdict("FAILED", 1, [], f"FAILED (strict fallback): {failed}") + return _FallbackVerdict("SUCCESS", 0, [], "SUCCESS (strict fallback): all green") # --------------------------------------------------------------------------- # Colour helpers @@ -152,23 +192,47 @@ def _sign_payload(secret: str, body: bytes) -> str: class Results: def __init__(self): + # _items keeps the (label, passed, detail) 3-tuple shape that existing + # ORCH-048 B6 tests unpack — categories live in a PARALLEL list so the + # public tuple contract is unchanged. self._items: list[tuple[str, bool, str]] = [] # (label, passed, detail) + self._categories: list[str] = [] # ORCH-061: REAL | SANDBOX_INFRA - def add(self, label: str, passed: bool, detail: str = ""): + def add(self, label: str, passed: bool, detail: str = "", category: str | None = None): + # ORCH-061: every check carries a category. None -> auto-classify by label + # (C9a/C9b -> SANDBOX_INFRA, everything else -> REAL). Fail-closed: an + # unknown label is REAL, so it still counts toward the safety net. + if category is None: + category = _classify(label) self._items.append((label, passed, detail)) + self._categories.append(category) line = _ok(label) if passed else _fail(label) if detail: line += f" [{detail}]" print(line) + def categorized_items(self) -> list[tuple[str, bool, str]]: + """Rows as ``(label, passed, category)`` for ``compute_staging_verdict``.""" + return [ + (label, passed, cat) + for (label, passed, _detail), cat in zip(self._items, self._categories) + ] + def summary(self) -> bool: passed = sum(1 for _, ok, _ in self._items if ok) total = len(self._items) all_ok = passed == total colour = _GREEN if all_ok else _RED + # ORCH-061: per-category breakdown so an operator can tell a REAL failure + # (regression — fail-closed) from a SANDBOX_INFRA one (waivable). + rows = self.categorized_items() + real_fail = [lbl for lbl, ok, cat in rows if not ok and cat == _REAL] + infra_fail = [lbl for lbl, ok, cat in rows if not ok and cat == _SANDBOX_INFRA] print() print(f"{_BOLD}{'='*60}{_RESET}") print(f"{colour}{_BOLD} RESULT: {passed}/{total} checks PASS{_RESET}") + print(f" REAL failed : {real_fail or 'none'}") + print(f" SANDBOX_INFRA failed: {infra_fail or 'none'}") print(f"{_BOLD}{'='*60}{_RESET}") return all_ok @@ -637,6 +701,28 @@ def _cleanup(plane_base, workspace, gitea_base, plane_headers, gitea_headers, # Main # --------------------------------------------------------------------------- +def _resolve_tolerance(cli_strict: bool) -> bool: + """Resolve whether the infra-FAIL waiver is active (ORCH-061). + + Precedence: an explicit ``--strict`` CLI flag forces it OFF (for honest manual + runs). Otherwise read ``settings.staging_infra_tolerance_enabled`` from the + running instance's own config (same pattern as B6's src.* import inside the + container). On ANY import/read error -> STRICT (False): we never waive when the + config is unreadable (fail-safe), and we say so. + """ + if cli_strict: + print(_info("tolerance: DISABLED via --strict (honest run)")) + return False + try: + from src.config import settings # noqa: WPS433 - lazy, mirrors B6 + enabled = bool(settings.staging_infra_tolerance_enabled) + print(_info(f"tolerance: staging_infra_tolerance_enabled={enabled}")) + return enabled + except Exception as e: + print(_info(f"tolerance: config unavailable, defaulting to STRICT: {e}")) + return False + + def main(): parser = argparse.ArgumentParser( description="Live staging-stand check suite (ORCH-33)" @@ -656,6 +742,15 @@ def main(): "full-real: also wait for the analyst agent (slow, costs credits)." ), ) + parser.add_argument( + "--strict", + action="store_true", + help=( + "ORCH-061: force strict suite — disable the sandbox-infra (C9a/C9b) " + "FAIL waiver even if staging_infra_tolerance_enabled=True. Use for an " + "honest 10/10 run once the sandbox bot accounts are provisioned." + ), + ) args = parser.parse_args() base = args.base_url.rstrip("/") @@ -673,8 +768,23 @@ def main(): block_b(results) block_c(base, results, args.mode) - all_ok = results.summary() - sys.exit(0 if all_ok else 1) + results.summary() + + # ORCH-061: the EXIT CODE (which drives the deployer's staging_status verdict) + # comes from the infra-tolerant verdict, NOT a raw passed==total count. A run + # whose only failures are known sandbox-infra checks (C9a/C9b) is waived to + # exit 0 when tolerance is on; ANY real check failure still exits 1 (FR-4). + infra_tolerant = _resolve_tolerance(args.strict) + verdict = _verdict(results.categorized_items(), infra_tolerant) + if verdict.waived: + # FR-7 observability: make "green with an allowance" distinguishable from + # an honest green in the logs / captured deployer output. + print(f"{_YELLOW}{_BOLD}INFRA-WAIVED:{_RESET} " + f"{', '.join(verdict.waived)} " + f"(known sandbox-infra; real checks green)") + print(f"{_BOLD}VERDICT:{_RESET} {verdict.status} " + f"(exit {verdict.exit_code}) — {verdict.summary}") + sys.exit(verdict.exit_code) if __name__ == "__main__": diff --git a/src/agents/launcher.py b/src/agents/launcher.py index 9d7598b..31454ef 100644 --- a/src/agents/launcher.py +++ b/src/agents/launcher.py @@ -20,6 +20,33 @@ logger = logging.getLogger("orchestrator.launcher") # never passed through to the CLI. VALID_EFFORTS = frozenset({"low", "medium", "high", "xhigh", "max"}) +# ORCH-061: action stages whose success is an ACTION (restart/retag), not a src +# edit — so "no changes to commit" is EXPECTED there, not under-delivery (FR-3). +_ACTION_STAGES = frozenset({"deploy-staging", "deploy"}) + + +def action_stage_no_changes_note(stage, repo) -> str | None: + """ORCH-061 (FR-3 / FR-7): observability for an empty diff on an action stage. + + The ``deploy-staging`` / ``deploy`` stages are actions (restart / retag), not + code edits, so the post-run "no changes to commit" is the NORMAL case there — + advancement is decided by the agent exit-code + the staging/deploy gate verdict, + NEVER by the presence of a commit (FR-3 / AC-4). This is a PURE decision used + only to emit an explicit log line distinguishing an expected action-stage no-op + from a code-stage no-op; it has no effect on stage advancement. + + Returns an explicit note string when the empty diff is expected (an action + stage of a self-deploy repo), else ``None``. Never raises. + """ + try: + if stage in _ACTION_STAGES: + from ..self_deploy import self_deploy_applies + if self_deploy_applies(repo): + return f"{stage}: no code changes (expected on action stage)" + return None + except Exception: # noqa: BLE001 - observability only, never raise + return None + def _resolve_agent_attr(agent, project_id, project_map_attr, env_attr_prefix, default_attr): @@ -582,6 +609,22 @@ class AgentLauncher: logger.warning(f"Agent run_id={run_id}: commit failed: {commit_result.stderr}") else: logger.info(f"Agent run_id={run_id}: no changes to commit") + # ORCH-061: on a self-deploy action stage (deploy-staging/deploy) + # an empty diff is EXPECTED (action, not a src edit). Emit an + # explicit observability line so an operator can tell this apart + # from a code-stage no-op. Does NOT affect advancement (decided by + # exit-code + gate verdict, never by a commit existing). + try: + _t = get_task_by_repo_branch(repo, branch) + _stage = _t["stage"] if _t else None + _note = action_stage_no_changes_note(_stage, repo) + if _note: + logger.info(f"Agent run_id={run_id}: {_note}") + except Exception as _e: + logger.debug( + f"Agent run_id={run_id}: action-stage no-changes note " + f"skipped: {_e}" + ) except Exception as e: logger.error(f"Agent run_id={run_id}: post-run git failed: {e}") diff --git a/src/config.py b/src/config.py index c2781b2..1a0612b 100644 --- a/src/config.py +++ b/src/config.py @@ -219,6 +219,22 @@ class Settings(BaseSettings): image_freshness_enabled: bool = True image_freshness_repos: str = "" + # ORCH-061: tolerate KNOWN sandbox-infra FAILs (C9a/C9b) in the staging suite. + # The self-hosting deploy-staging stage looped because scripts/staging_check.py + # exited non-zero on ANY failed check, so two infra-only failures (sandbox bot + # accounts not members of the sandbox Plane project) produced staging_status: + # FAILED -> rollback deploy-staging -> development -> loop. + # True -> a run whose ONLY failures are allowlisted sandbox-infra checks + # (C9a/C9b) is waived to SUCCESS; ANY real pipeline check that fails + # still fails closed -> FAILED -> rollback (safety net intact, FR-4). + # False -> 1:1 pre-ORCH-061 strict behaviour: any FAIL -> FAILED -> rollback. + # Default True (mirrors merge_gate_enabled / image_freshness_enabled / + # self_deploy_enabled): the safety net holds regardless of the flag; the flag + # exists to instantly restore legacy strictness without a code redeploy. Lives + # in .env.staging (ORCH_ prefix) so it is reachable inside orchestrator-staging. + # Env ORCH_STAGING_INFRA_TOLERANCE_ENABLED. + staging_infra_tolerance_enabled: bool = True + # 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 diff --git a/src/staging_verdict.py b/src/staging_verdict.py new file mode 100644 index 0000000..1766248 --- /dev/null +++ b/src/staging_verdict.py @@ -0,0 +1,173 @@ +"""ORCH-061: pure staging-verdict logic (classification + tolerant verdict). + +The self-hosting ``orchestrator`` looped on ``deploy-staging`` because +``scripts/staging_check.py`` summed ``all_ok = passed == total`` and exited +non-zero on ANY failed check — so two *infrastructure-only* failures (C9a branch +not found / C9b analyst-job not in queue, both caused by the SANDBOX bot accounts +not being members of the sandbox Plane project) produced ``staging_status: +FAILED`` → rollback ``deploy-staging → development`` → loop (ADR-001 §Context). + +This module isolates the **pure verdict logic** so both outcomes are unit-testable +without a live staging stand or docker (TRZ §9): + + * ``classify_check(label)`` — label → ``REAL`` | ``SANDBOX_INFRA`` (narrow, + allowlist-driven, fail-closed to ``REAL`` on anything unrecognised); + * ``compute_staging_verdict(items, infra_tolerant)`` — fold the per-check + pass/fail + category into a single ``StagingVerdict``. + +It is a **leaf**: stdlib only, no I/O, no project imports — so it is safe to import +both from the orchestrator process and from ``scripts/staging_check.py`` (which +runs inside the ``orchestrator-staging`` container, pattern B6 / ORCH-048). Every +public function honours a **never-raise** contract: on any malformed input it +returns the *conservative* (fail-closed) result, never an exception. + +Safety invariant (FR-4 / AC-3): a failed REAL check ALWAYS yields ``FAILED`` / +exit 1 regardless of ``infra_tolerant``. The waiver applies ONLY to the named +``SANDBOX_INFRA`` checks and ONLY when every REAL check (incl. C7/C8) is green — +so the blast-radius of the tolerance is exactly the two allowlisted checks. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +# Category constants --------------------------------------------------------- +REAL = "real" # a real pipeline check — fail-closed, always counts +SANDBOX_INFRA = "sandbox_infra" # known to depend on sandbox infra (waivable) + +# Narrow allowlist of checks known to depend on sandbox infrastructure rather +# than the pipeline itself (ADR-001 §1). Matched by the check's leading label +# token, e.g. "C9a Branch appears in orchestrator-sandbox" -> token "C9a". +# Keep this set MINIMAL — every entry is a hole in the staging safety-net. +SANDBOX_INFRA_CHECKS = frozenset({"C9a", "C9b"}) + + +def classify_check(label) -> str: + """Classify a staging-check label as ``REAL`` or ``SANDBOX_INFRA``. + + A label is ``SANDBOX_INFRA`` iff its leading whitespace-delimited token is one + of :data:`SANDBOX_INFRA_CHECKS` (exact match or prefix, e.g. ``"C9a"`` from + ``"C9a Branch appears…"``). Everything else — and anything unrecognised / + malformed — is ``REAL`` (conservative / fail-closed: an unknown check counts + toward the safety net). Never raises. + """ + try: + text = str(label).strip() + if not text: + return REAL + token = text.split()[0] + for prefix in SANDBOX_INFRA_CHECKS: + if token == prefix or token.startswith(prefix): + return SANDBOX_INFRA + return REAL + except Exception: + return REAL + + +@dataclass +class StagingVerdict: + """Outcome of folding the staging-check suite into a single verdict. + + ``status`` — ``"SUCCESS"`` | ``"FAILED"`` (mirrors the ``staging_status:`` + frontmatter contract the deployer writes; unchanged). + ``exit_code`` — ``0`` (advance) | ``1`` (rollback). Drives ``sys.exit`` in + ``staging_check.py``. + ``waived`` — labels of SANDBOX_INFRA checks that failed but were tolerated + (empty unless the waiver actually fired — observability, FR-7). + ``summary`` — human-readable one-liner for logs. + """ + + status: str + exit_code: int + waived: list = field(default_factory=list) + summary: str = "" + + +def _coerce_item(item) -> tuple[str, bool, str]: + """Normalise an input row into ``(label, passed, category)``. + + Accepts ``(label, passed)`` or ``(label, passed, category)``. A missing/None + category is resolved via :func:`classify_check`. Never raises — a malformed + row degrades to a failed REAL check (fail-closed) so it cannot silently pass. + """ + try: + label = str(item[0]) + passed = bool(item[1]) + category = item[2] if len(item) > 2 and item[2] else None + except Exception: + return ("", False, REAL) + if category not in (REAL, SANDBOX_INFRA): + category = classify_check(label) + return (label, passed, category) + + +def compute_staging_verdict(items, infra_tolerant: bool) -> StagingVerdict: + """Fold per-check results into a tolerant-but-fail-closed staging verdict. + + ``items`` — iterable of ``(label, passed: bool[, category: str])``. + + Decision table (ADR-001 §1): + * any REAL check failed -> FAILED / exit 1 (safety net) + * only SANDBOX_INFRA failed & infra_tolerant -> SUCCESS / exit 0 (waived) + * only SANDBOX_INFRA failed & !infra_tolerant -> FAILED / exit 1 (legacy strict) + * nothing failed -> SUCCESS / exit 0 + + Never raises: on any internal error the verdict degrades to a conservative + ``FAILED`` / exit 1 (never a false green) — AC-10. + """ + try: + real_failed: list[str] = [] + infra_failed: list[str] = [] + for raw in items: + label, passed, category = _coerce_item(raw) + if passed: + continue + if category == SANDBOX_INFRA: + infra_failed.append(label) + else: + real_failed.append(label) + + if real_failed: + # Safety net (FR-4): a real pipeline regression always fails closed, + # regardless of tolerance. Infra failures (if any) are noted but the + # verdict is dominated by the real failure. + extra = f"; infra-fail {infra_failed}" if infra_failed else "" + return StagingVerdict( + status="FAILED", + exit_code=1, + waived=[], + summary=f"FAILED: real checks failed {real_failed}{extra}", + ) + if infra_failed and infra_tolerant: + # Waiver fires ONLY here: every REAL check is green and the only + # failures are allowlisted sandbox-infra checks (FR-2). + return StagingVerdict( + status="SUCCESS", + exit_code=0, + waived=list(infra_failed), + summary=( + f"SUCCESS (infra-waived): {infra_failed} are known sandbox-infra " + "checks; all real checks green" + ), + ) + if infra_failed and not infra_tolerant: + # Legacy strict (kill-switch off): any failure fails closed (1:1 pre-061). + return StagingVerdict( + status="FAILED", + exit_code=1, + waived=[], + summary=f"FAILED (strict): {infra_failed} failed and tolerance disabled", + ) + return StagingVerdict( + status="SUCCESS", + exit_code=0, + waived=[], + summary="SUCCESS: all checks green", + ) + except Exception as e: # noqa: BLE001 - never-raise; fail closed on doubt + return StagingVerdict( + status="FAILED", + exit_code=1, + waived=[], + summary=f"FAILED (verdict error, fail-closed): {e}", + ) diff --git a/tests/test_config.py b/tests/test_config.py index b751be4..ea44e0c 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -142,3 +142,26 @@ def test_image_freshness_settings_env_override(monkeypatch): s = Settings() assert s.image_freshness_enabled is False assert s.image_freshness_repos == "orchestrator,enduro-trails" + + +# --------------------------------------------------------------------------- +# ORCH-061 / TC-09: staging_infra_tolerance_enabled kill-switch (AC-7). +# --------------------------------------------------------------------------- +def test_staging_infra_tolerance_defaults_true(monkeypatch): + """TC-09 / AC-7: the kill-switch defaults ON (safe default — the safety net + holds regardless; the flag exists to restore legacy strictness instantly).""" + monkeypatch.delenv("ORCH_STAGING_INFRA_TOLERANCE_ENABLED", raising=False) + assert Settings().staging_infra_tolerance_enabled is True + + +def test_staging_infra_tolerance_env_override_false(monkeypatch): + """TC-09 / AC-7: ORCH_STAGING_INFRA_TOLERANCE_ENABLED=false -> strict (1:1 + pre-ORCH-061: infra-only FAIL again rolls back).""" + monkeypatch.setenv("ORCH_STAGING_INFRA_TOLERANCE_ENABLED", "false") + assert Settings().staging_infra_tolerance_enabled is False + + +def test_staging_infra_tolerance_env_override_true(monkeypatch): + """The field is read verbatim from its ORCH_* env var.""" + monkeypatch.setenv("ORCH_STAGING_INFRA_TOLERANCE_ENABLED", "true") + assert Settings().staging_infra_tolerance_enabled is True diff --git a/tests/test_launcher.py b/tests/test_launcher.py index e2ec215..970f226 100644 --- a/tests/test_launcher.py +++ b/tests/test_launcher.py @@ -278,3 +278,48 @@ class TestWatchdogGracefulKill: assert signal.SIGKILL not in sent assert recorded["called"] is False + + +# --------------------------------------------------------------------------- +# ORCH-061 / TC-06 + TC-07: "no changes to commit" on an action stage is EXPECTED, +# not under-delivery (FR-3 / AC-4). action_stage_no_changes_note is the PURE +# observability decision used by the post-run no-changes branch: it returns an +# explicit note for self-deploy action stages (deploy-staging/deploy) and None +# everywhere else. It NEVER signals a rollback — advancement is decided by the +# exit-code + gate verdict, never by a commit existing. +# --------------------------------------------------------------------------- +from src.agents.launcher import action_stage_no_changes_note # noqa: E402 + + +class TestActionStageNoChangesNote: + def test_tc06_deploy_staging_self_deploy_returns_note(self): + """TC-06 / AC-4: on deploy-staging for the self-hosting repo, an empty diff + yields an explicit "expected on action stage" note (no rollback signal).""" + note = action_stage_no_changes_note("deploy-staging", "orchestrator") + assert note is not None + assert "deploy-staging" in note + assert "expected on action stage" in note + + def test_tc06_deploy_self_deploy_returns_note(self): + """The `deploy` stage is equally an action stage for self-deploy.""" + note = action_stage_no_changes_note("deploy", "orchestrator") + assert note is not None + assert "deploy: no code changes" in note + + def test_tc07_development_stage_returns_none(self): + """TC-07 / AC-4 regression-guard: on a CODE stage (development) the new + action-stage allowance does NOT apply — no note, behaviour unchanged.""" + assert action_stage_no_changes_note("development", "orchestrator") is None + + def test_tc06_non_self_repo_returns_none(self): + """Conditionality (FR-5): the action-stage allowance is self-deploy only; + a non-self repo on deploy-staging gets no special note.""" + assert action_stage_no_changes_note("deploy-staging", "enduro-trails") is None + + def test_review_stage_returns_none(self): + """Any non-action stage -> None (defensive: only deploy stages qualify).""" + assert action_stage_no_changes_note("review", "orchestrator") is None + + def test_never_raises_on_bad_input(self): + """never-raise: odd inputs (None stage / None repo) degrade to None.""" + assert action_stage_no_changes_note(None, None) is None diff --git a/tests/test_qg.py b/tests/test_qg.py index eb41680..b318c85 100644 --- a/tests/test_qg.py +++ b/tests/test_qg.py @@ -689,6 +689,27 @@ class TestCheckStagingStatus: assert passed is True assert "N/A" in reason + # ------------------------------------------------------------------ + # ORCH-061 / TC-08: the conditional staging gate is unchanged for + # non-self-hosting repos AND independent of the new tolerance flag (FR-5/AC-6). + # ------------------------------------------------------------------ + + def test_tc08_non_self_na_independent_of_tolerance_flag(self, tmp_path, monkeypatch): + """TC-08 / AC-6: for a non-self-hosting repo check_staging_status is the + byte-identical (True, "Staging gate N/A …") regardless of whether the + ORCH-061 infra-tolerance flag is on or off — the new behaviour never + activates off the self-hosting path.""" + monkeypatch.setattr("src.qg.checks.settings.repos_dir", str(tmp_path)) + from src.qg.checks import check_staging_status + for flag in (True, False): + monkeypatch.setattr( + "src.config.settings.staging_infra_tolerance_enabled", flag, + raising=False, + ) + passed, reason = check_staging_status("enduro-trails", "ET-035") + assert passed is True + assert reason == "Staging gate N/A for enduro-trails" + # ------------------------------------------------------------------ # is_self_hosting_repo helper # ------------------------------------------------------------------ diff --git a/tests/test_qg_checks.py b/tests/test_qg_checks.py index c6bfb01..2ab3ea2 100644 --- a/tests/test_qg_checks.py +++ b/tests/test_qg_checks.py @@ -51,3 +51,100 @@ def test_tc15_finalizer_log_roundtrips_through_parser(): ok_f, _ = _parse_deploy_status(build_deploy_log("ORCH-036", 2, "FAILED")) assert ok_s is True assert ok_f is False + + +# --------------------------------------------------------------------------- +# ORCH-061 / TC-04 + TC-05: infra-tolerant staging verdict (pure logic, AC-2/AC-3). +# +# compute_staging_verdict folds the staging-check suite into a single +# SUCCESS/FAILED verdict that is TOLERANT to known sandbox-infra failures +# (C9a/C9b) but stays fail-closed for any REAL pipeline check. These tests +# exercise the verdict directly — no live staging stand / docker (02-trz §9). +# --------------------------------------------------------------------------- +from src.staging_verdict import ( # noqa: E402 + REAL, + SANDBOX_INFRA, + compute_staging_verdict, +) + + +def _rows(*specs): + """Helper: build (label, passed, category) rows.""" + return [(label, passed, cat) for label, passed, cat in specs] + + +def test_tc04_only_infra_failures_waived_to_success(): + """TC-04 / AC-2: every REAL check PASS, only known sandbox-infra checks + (C9a/C9b) FAIL, tolerance ON -> SUCCESS / exit 0 (no false rollback).""" + rows = _rows( + ("C7 Create issue in Plane SANDBOX", True, REAL), + ("C8 Trigger pipeline via /webhook/plane", True, REAL), + ("C9a Branch appears in orchestrator-sandbox", False, SANDBOX_INFRA), + ("C9b Analyst job enqueued in staging queue", False, SANDBOX_INFRA), + ) + v = compute_staging_verdict(rows, infra_tolerant=True) + assert v.status == "SUCCESS" + assert v.exit_code == 0 + # Both infra checks are surfaced as waived (observability, FR-7). + assert set(v.waived) == { + "C9a Branch appears in orchestrator-sandbox", + "C9b Analyst job enqueued in staging queue", + } + + +def test_tc05_any_real_failure_fails_closed(): + """TC-05 / AC-3: at least one REAL pipeline check FAILS (alongside the infra + ones) -> FAILED / exit 1 even with tolerance ON (safety net not weakened).""" + rows = _rows( + ("C7 Create issue in Plane SANDBOX", False, REAL), # real regression + ("C8 Trigger pipeline via /webhook/plane", True, REAL), + ("C9a Branch appears in orchestrator-sandbox", False, SANDBOX_INFRA), + ) + v = compute_staging_verdict(rows, infra_tolerant=True) + assert v.status == "FAILED" + assert v.exit_code == 1 + assert v.waived == [] # nothing waived when a real check failed + + +def test_tc05_real_failure_fails_closed_even_alone(): + """A single REAL failure (no infra failures) is still FAILED (fail-closed).""" + rows = _rows(("C7 Create issue in Plane SANDBOX", False, REAL)) + v = compute_staging_verdict(rows, infra_tolerant=True) + assert v.status == "FAILED" + assert v.exit_code == 1 + + +def test_tc09_infra_failure_strict_mode_fails_closed(): + """TC-09 / AC-7: with tolerance OFF, an infra-only FAIL again -> FAILED + (1:1 pre-ORCH-061 strict behaviour).""" + rows = _rows( + ("C7 Create issue in Plane SANDBOX", True, REAL), + ("C9a Branch appears in orchestrator-sandbox", False, SANDBOX_INFRA), + ) + v = compute_staging_verdict(rows, infra_tolerant=False) + assert v.status == "FAILED" + assert v.exit_code == 1 + + +def test_all_green_is_success_regardless_of_tolerance(): + rows = _rows( + ("C7 Create issue in Plane SANDBOX", True, REAL), + ("C9a Branch appears in orchestrator-sandbox", True, SANDBOX_INFRA), + ) + for tol in (True, False): + v = compute_staging_verdict(rows, infra_tolerant=tol) + assert v.status == "SUCCESS" + assert v.exit_code == 0 + assert v.waived == [] + + +def test_tc12_compute_verdict_never_raises_on_garbage(): + """AC-10 never-raise: malformed rows degrade to a conservative FAILED, never + an exception.""" + v = compute_staging_verdict([("only-one-element",)], infra_tolerant=True) + assert v.status == "FAILED" + assert v.exit_code == 1 + # A completely broken iterable also fails closed without raising. + v2 = compute_staging_verdict(None, infra_tolerant=True) + assert v2.status == "FAILED" + assert v2.exit_code == 1 diff --git a/tests/test_stage_engine.py b/tests/test_stage_engine.py index f229141..ca3dab6 100644 --- a/tests/test_stage_engine.py +++ b/tests/test_stage_engine.py @@ -1132,3 +1132,158 @@ class TestDelegation: assert args[0] == 5 assert args[1] == "analysis" assert args[-1] is None + + +# --------------------------------------------------------------------------- +# ORCH-061: no deploy-staging loop on a healthy self-deploy; the ORCH-35 safety +# net (real staging FAIL -> rollback) stays intact; the new logic never raises +# into advance_stage; and "green with an infra allowance" is distinguishable from +# an honest green (observability). +# --------------------------------------------------------------------------- +class TestStagingInfraTolerance: + """The verdict that produces ``staging_status:`` is computed in the suite + BEFORE the gate (ORCH-061 ADR-001 §4: check_staging_status is unchanged). At + the engine level we therefore assert the REACTION to the resulting verdict: + SUCCESS advances (no loop), a REAL FAILED rolls back (safety net).""" + + def _patch_self_deploy_state(self, monkeypatch, tmp_path): + # Phase A writes restart-safe markers under repos_dir — keep them in tmp. + monkeypatch.setattr(stage_engine.self_deploy.settings, "repos_dir", str(tmp_path)) + monkeypatch.setattr(stage_engine.self_deploy.settings, "host_repos_dir", str(tmp_path)) + + def test_tc01_healthy_self_deploy_advances_no_rollback(self, monkeypatch, tmp_path): + """TC-01 / AC-1: staging SUCCESS (infra-FAIL already waived in the suite) + + green merge/freshness sub-gates -> deploy-staging advances to `deploy` + (Phase A approval-pending). NO rollback to development (loop is gone).""" + self._patch_self_deploy_state(monkeypatch, tmp_path) + 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-061", + branch="feature/ORCH-061-x") + res = advance_stage( + task_id, "deploy-staging", "orchestrator", "ORCH-061", + "feature/ORCH-061-x", finished_agent="deployer", + ) + assert res.advanced is True + assert res.to_stage == "deploy" + assert _stage(task_id) == "deploy" # Phase A advanced the stage + assert res.rolled_back_to is None # NO loop back to development + assert res.note == "self-deploy-approval-pending" + + def test_tc02_real_staging_failed_rolls_back(self, monkeypatch, tmp_path): + """TC-02 / AC-3: a REAL staging failure (verdict FAILED) still rolls + deploy-staging back to development + set_issue_blocked + alert — the + ORCH-35 safety net is NOT weakened by the infra tolerance (FR-4).""" + self._patch_self_deploy_state(monkeypatch, tmp_path) + monkeypatch.setattr( + stage_engine, "QG_CHECKS", + {**stage_engine.QG_CHECKS, + "check_staging_status": _fail("Staging status: FAILED")}, + ) + task_id = _make_task("deploy-staging", repo="orchestrator", wi="ORCH-061", + branch="feature/ORCH-061-x") + res = advance_stage( + task_id, "deploy-staging", "orchestrator", "ORCH-061", + "feature/ORCH-061-x", finished_agent="deployer", + ) + assert res.advanced is False + assert res.rolled_back_to == "development" + assert _stage(task_id) == "development" + assert res.alerted is True + assert stage_engine.set_issue_blocked.called + assert stage_engine.send_telegram.called + + def test_tc12_gate_exception_never_crashes_advance(self, monkeypatch, tmp_path): + """TC-12 / AC-10 never-raise: if the staging gate raises (io/parse/docker + hiccup), advance_stage catches it deterministically — no exception escapes, + the task does NOT advance and is NOT falsely rolled back to development.""" + self._patch_self_deploy_state(monkeypatch, tmp_path) + + def _boom(*a, **k): + raise RuntimeError("staging gate blew up") + + monkeypatch.setattr( + stage_engine, "QG_CHECKS", + {**stage_engine.QG_CHECKS, "check_staging_status": _boom}, + ) + task_id = _make_task("deploy-staging", repo="orchestrator", wi="ORCH-061", + branch="feature/ORCH-061-x") + # Must NOT raise. + res = advance_stage( + task_id, "deploy-staging", "orchestrator", "ORCH-061", + "feature/ORCH-061-x", finished_agent="deployer", + ) + assert res.advanced is False + assert res.rolled_back_to is None # exception != gate FAILED + assert _stage(task_id) == "deploy-staging" # stays put, no loop + assert res.note and "error" in res.note + + def test_tc13_end_to_end_self_deploy_no_single_rollback(self, monkeypatch, tmp_path): + """TC-13 / AC-1+AC-4 integration: a healthy self-deploy goes + deploy-staging -> deploy (Phase A) -> (approve/finalize SUCCESS) -> done + WITHOUT a single rollback to development in the transition log.""" + self._patch_self_deploy_state(monkeypatch, tmp_path) + monkeypatch.setattr( + stage_engine, "QG_CHECKS", + {**stage_engine.QG_CHECKS, + "check_staging_status": _pass, + "check_branch_mergeable": _pass, + "check_staging_image_fresh": _pass, + "check_deploy_status": _pass}, + ) + task_id = _make_task("deploy-staging", repo="orchestrator", wi="ORCH-061", + branch="feature/ORCH-061-x") + + seen_stages = [] + + # 1) deploy-staging -> deploy (Phase A approval-pending). + r1 = advance_stage( + task_id, "deploy-staging", "orchestrator", "ORCH-061", + "feature/ORCH-061-x", finished_agent="deployer", + ) + seen_stages.append(_stage(task_id)) + assert r1.advanced is True + assert _stage(task_id) == "deploy" + + # 2) finalizer (Phase C): deploy verdict SUCCESS -> done. + r2 = advance_stage( + task_id, "deploy", "orchestrator", "ORCH-061", + "feature/ORCH-061-x", finished_agent="deployer", + ) + seen_stages.append(_stage(task_id)) + assert r2.advanced is True + assert _stage(task_id) == "done" + + # Not a single rollback to development anywhere in the path. + assert "development" not in seen_stages + assert r1.rolled_back_to is None and r2.rolled_back_to is None + + def test_tc14_waived_green_distinguishable_from_honest_green(self): + """TC-14 / AC-11 observability: the staging verdict makes "green with an + infra allowance" distinguishable from an honest green — the waived list is + populated and the summary says so, vs an empty waived list + plain summary + for an all-green run.""" + from src.staging_verdict import REAL, SANDBOX_INFRA, compute_staging_verdict + + waived = compute_staging_verdict( + [("C7", True, REAL), + ("C9a", False, SANDBOX_INFRA)], + infra_tolerant=True, + ) + honest = compute_staging_verdict( + [("C7", True, REAL), + ("C9a", True, SANDBOX_INFRA)], + infra_tolerant=True, + ) + # Both advance... + assert waived.status == honest.status == "SUCCESS" + # ...but only the waived one carries the explicit allowance marker. + assert waived.waived == ["C9a"] + assert "infra-waived" in waived.summary.lower() + assert honest.waived == [] + assert "infra-waived" not in honest.summary.lower() diff --git a/tests/test_staging_check_b6.py b/tests/test_staging_check_b6.py index 0eb8940..a2668f8 100644 --- a/tests/test_staging_check_b6.py +++ b/tests/test_staging_check_b6.py @@ -149,3 +149,70 @@ def test_run_b6_records_pass_for_clean_registry(monkeypatch): _label, passed, detail = results._items[0] assert passed is True assert "sandbox=YES" in detail + + +# --------------------------------------------------------------------------- +# ORCH-061 / TC-03: the suite classifies checks as REAL vs SANDBOX_INFRA so the +# verdict (and exit-code) can tolerate KNOWN sandbox-infra FAILs (C9a/C9b) while +# staying fail-closed for real pipeline checks. Tested without a live stand. +# --------------------------------------------------------------------------- +from src.staging_verdict import REAL, SANDBOX_INFRA # noqa: E402 + + +def test_tc03_classify_infra_checks(): + """C9a/C9b classify as SANDBOX_INFRA; pipeline checks (A/B/C7/C8) as REAL.""" + assert sc._classify("C9a Branch appears in orchestrator-sandbox") == SANDBOX_INFRA + assert sc._classify("C9b Analyst job enqueued in staging queue") == SANDBOX_INFRA + assert sc._classify("C7 Create issue in Plane SANDBOX") == REAL + assert sc._classify("C8 Trigger pipeline via /webhook/plane") == REAL + assert sc._classify("A1 GET /health") == REAL + assert sc._classify("B6 Registry: sandbox present") == REAL + + +def test_tc03_results_records_categories_and_keeps_tuple_shape(): + """Results.add auto-classifies each check; categorized_items() exposes the + category WITHOUT changing the public 3-tuple shape of _items (ORCH-048 b6 + tests still unpack (label, passed, detail)).""" + results = sc.Results() + results.add("C7 Create issue in Plane SANDBOX", True) + results.add("C9a Branch appears in orchestrator-sandbox", False) + + # Public _items shape unchanged (regression guard for ORCH-048 tests). + for item in results._items: + assert len(item) == 3 + + cats = {label: cat for label, _passed, cat in results.categorized_items()} + assert cats["C7 Create issue in Plane SANDBOX"] == REAL + assert cats["C9a Branch appears in orchestrator-sandbox"] == SANDBOX_INFRA + + +def test_tc03_explicit_category_overrides_autoclassify(): + """An explicit category arg is honoured (caller can force REAL).""" + results = sc.Results() + results.add("C9a Branch appears in orchestrator-sandbox", False, category=REAL) + label, _passed, cat = results.categorized_items()[0] + assert cat == REAL + + +def test_tc03_suite_verdict_waives_infra_only_failure(): + """End-to-end through the suite helpers: a run whose only failures are C9a/C9b + -> exit 0 (waived) under tolerance; the waiver is surfaced for observability.""" + results = sc.Results() + results.add("C7 Create issue in Plane SANDBOX", True) + results.add("C8 Trigger pipeline via /webhook/plane", True) + results.add("C9a Branch appears in orchestrator-sandbox", False) + results.add("C9b Analyst job enqueued in staging queue", False) + + verdict = sc._verdict(results.categorized_items(), infra_tolerant=True) + assert verdict.status == "SUCCESS" + assert verdict.exit_code == 0 + assert len(verdict.waived) == 2 + + # Strict mode (kill-switch off) re-fails the same run. + strict = sc._verdict(results.categorized_items(), infra_tolerant=False) + assert strict.exit_code == 1 + + +def test_tc03_resolve_tolerance_strict_flag_forces_off(): + """--strict forces tolerance OFF regardless of the config default.""" + assert sc._resolve_tolerance(cli_strict=True) is False From fe8586ed78b3447b4b92a562fc8a44cdae71c284 Mon Sep 17 00:00:00 2001 From: stream Date: Sun, 7 Jun 2026 13:04:38 +0000 Subject: [PATCH 5/9] ci: re-run after host disk cleanup (ORCH-061) From 32a7aa8c6b6e7749687f86e5cf06ea493212a17d Mon Sep 17 00:00:00 2001 From: stream Date: Sun, 7 Jun 2026 13:08:38 +0000 Subject: [PATCH 6/9] ci: trigger re-run after host disk cleanup (ORCH-061) --- .task-arch.md | 4 ++++ .task-dev.md | 4 ++++ .task.md | 8 ++++++++ docs/work-items/ORCH-061/10-tech-risks.md | 1 + 4 files changed, 17 insertions(+) create mode 100644 .task-arch.md create mode 100644 .task-dev.md create mode 100644 .task.md diff --git a/.task-arch.md b/.task-arch.md new file mode 100644 index 0000000..087e243 --- /dev/null +++ b/.task-arch.md @@ -0,0 +1,4 @@ +Work item: ORCH-061 +Repo: orchestrator +Branch: feature/ORCH-061-bug-deploy-staging-development +Stage: architecture \ No newline at end of file diff --git a/.task-dev.md b/.task-dev.md new file mode 100644 index 0000000..ff21934 --- /dev/null +++ b/.task-dev.md @@ -0,0 +1,4 @@ +Work item: ORCH-061 +Repo: orchestrator +Branch: feature/ORCH-061-bug-deploy-staging-development +Stage: development \ No newline at end of file diff --git a/.task.md b/.task.md new file mode 100644 index 0000000..274f554 --- /dev/null +++ b/.task.md @@ -0,0 +1,8 @@ +Work item: ORCH-061 +Repo: orchestrator +Branch: feature/ORCH-061-bug-deploy-staging-development +Stage: analysis +Title: BUG: deploy-staging петля — откат на development (self-deploy) + +Description: +Симптом: на стадии deploy-staging для self-hosting orchestrator задача откатывается deploy-staging -> development и крутится по кругу.ДВЕ подтверждённые причины (ORCH-58 + ORCH-60):1. check_staging_status FAILED (ложный). deployer гоняет staging_check.py, тот падает на C9a/C9b (sandbox e2e: branch not found + analyst job in queue) с пометкой «Plane comment check skipped: bot-tokens not added to SANDBOX project». 8/10 PASS, 2 ложных FAIL из-за ненастроенных bot-токенов SANDBOX-проекта. QG check_staging_status -> FAILED -> rollback deploy-staging->development. Это НЕ регресс кода, а отсутствие sandbox-настроек.2. no changes to commit. для action-стадий (деплой = рестарт/retag, не правка кода) deployer exit0 + «no changes» тоже трактуется stage_engine как недовыполнение -> откат.Последствие: прод-деплой self-hosting репо НЕВОЗМОЖЕН автономно — ORCH-58 и ORCH-60 доводились ВРУЧНУЮ (merge PR + build-once retag + --deploy). Прямой блокер автономного внедрения (эпик ORCH-54).Fix-направления (одно или оба):(а) Настроить sandbox bot-токены в SANDBOX Plane-проект, чтобы staging_check C9a/C9b проходили честно (10/10). Тогда check_staging_status не будет ложно падать.(б) Отвязать advance deploy-стадии от git-changes для self-deploy репо: успех = exit0 + health PASS (+ опц. staging_check), а не наличие коммита.Acceptance: ORCH-задача для self-hosting orchestrator проходит deploy-staging -> deploy -> Done БЕЗ ручного вмешательства и без петли. Priority P0. \ No newline at end of file diff --git a/docs/work-items/ORCH-061/10-tech-risks.md b/docs/work-items/ORCH-061/10-tech-risks.md index f13db54..c9bd805 100644 --- a/docs/work-items/ORCH-061/10-tech-risks.md +++ b/docs/work-items/ORCH-061/10-tech-risks.md @@ -21,3 +21,4 @@ Work Item: **ORCH-061** · Репо: `orchestrator` (self-hosting) - БАГ-8 (`deploy → development`) и ORCH-35 (`deploy-staging → development`) для **реальных** провалов — сохранены. - Схема БД — без миграций. +# ci-rerun 2026-06-07T13:08:38Z after disk cleanup From e3f7c1c27284c0252687e0dbbc93eb5ade776b0c Mon Sep 17 00:00:00 2001 From: stream Date: Sun, 7 Jun 2026 13:14:14 +0000 Subject: [PATCH 7/9] ci: re-trigger after gitea restart (ORCH-061) --- docs/work-items/ORCH-061/10-tech-risks.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/work-items/ORCH-061/10-tech-risks.md b/docs/work-items/ORCH-061/10-tech-risks.md index c9bd805..ae78c5e 100644 --- a/docs/work-items/ORCH-061/10-tech-risks.md +++ b/docs/work-items/ORCH-061/10-tech-risks.md @@ -22,3 +22,4 @@ Work Item: **ORCH-061** · Репо: `orchestrator` (self-hosting) **реальных** провалов — сохранены. - Схема БД — без миграций. # ci-rerun 2026-06-07T13:08:38Z after disk cleanup +# ci-rerun gitea-restarted 2026-06-07T13:14:14Z From de47737f4f8eb43a50972098d44ef748e8dd9797 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Sun, 7 Jun 2026 13:18:47 +0000 Subject: [PATCH 8/9] reviewer(ET): auto-commit from reviewer run_id=299 --- docs/work-items/ORCH-061/12-review.md | 88 +++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 docs/work-items/ORCH-061/12-review.md diff --git a/docs/work-items/ORCH-061/12-review.md b/docs/work-items/ORCH-061/12-review.md new file mode 100644 index 0000000..9c12378 --- /dev/null +++ b/docs/work-items/ORCH-061/12-review.md @@ -0,0 +1,88 @@ +--- +type: review +work_item_id: ORCH-061 +verdict: APPROVED +version: 1 +--- + +# Review ORCH-061 + +## Summary + +Исправление петли `deploy-staging → development` при self-hosting self-deploy. +Реализовано Direction (б) из ADR-001: классификация staging-проверок на `REAL` +(fail-closed) и `SANDBOX_INFRA` (узкий allowlist `{C9a, C9b}`, waivable) + +толерантный-но-fail-closed вердикт. + +Реализация **полностью соответствует ТЗ (02-trz.md), критериям приёмки +(03-acceptance-criteria.md) и ADR-001**. Все контракты сохранены, документация +обновлена в том же PR, тесты зелёные. + +Проверено по осям: + +- **Соответствие ТЗ:** FR-1…FR-7 закрыты. Новый leaf-модуль + `src/staging_verdict.py` (stdlib-only, never-raise), флаг + `staging_infra_tolerance_enabled` (kill-switch, default True), observability + через `INFRA-WAIVED:`/`VERDICT:` и `action_stage_no_changes_note`. +- **Соответствие ADR-001:** механизм, allowlist `{C9a, C9b}`, fail-closed для + REAL, waiver только когда все REAL (вкл. C7/C8) зелёные, `--strict`, + `_resolve_tolerance` (fail-safe → strict при нечитаемом конфиге) — реализовано + ровно как в «Решении» ADR. Затронутые файлы совпадают с таблицей ADR. +- **Контракты (AC-8):** `src/qg/checks.py` (`check_staging_status`/ + `_parse_staging_status`), `src/stages.py` (`STAGE_TRANSITIONS`, `QG_CHECKS`) + — **не изменены** (подтверждено `git diff`). Толерантность живёт в suite ДО + записи артефакта; новый QG-чек не вводится; реестр-снапшот цел. +- **Схема БД (AC-9):** миграций нет, флаг — только конфиг. +- **never-raise (AC-10):** `compute_staging_verdict`/`classify_check`/ + `_coerce_item`/`action_stage_no_changes_note` ловят всё и деградируют в + консервативный FAILED/None. Покрыто TC-12. +- **Условность self-hosting / страховка (AC-3/AC-5/AC-6):** rollback на реальном + FAIL сохранён (`tests/test_stage_engine.py` TestStaging*), поведение не-self + репо неизменно. +- **Тесты (AC-14):** `pytest tests/ -q` → **670 passed**. ORCH-061 покрытие: + TC-04 (infra waived → SUCCESS), TC-05 (REAL fail → FAILED), TC-09 (strict), + TC-12 (garbage never-raise), TC-06/TC-07 (action-stage no-changes note), + non-self репо. +- **Безопасность self-hosting (AC-12):** код задачи не трогает прод 8500; + сборки/recreate — вне пути этой логики. + +Примечание по диффу: при просмотре `git diff main...HEAD` появлялись файлы +ORCH-060 (reconciler, plane_sync, config reconcile-флаги). Это артефакт +**устаревшего локального ref `main`** — `origin/main` уже содержит ORCH-060 +(merge `d4c6cc0`, PR #60). Истинный `git diff origin/main...HEAD` — чистый +ORCH-061. Бандлинга чужого work-item нет. + +## Findings + +### P0 — Blocker +- нет + +### P1 — Must fix +- нет + +### P2 — Should fix +- [ ] **Стрэй-файлы агентного скрэтча закоммичены в репо:** `.task.md`, + `.task-arch.md`, `.task-dev.md` (хэндофф-файлы стадий analysis/architecture/ + development) попали в коммит и не покрыты `.gitignore`. Это засоряет репо и + будет повторяться каждый прогон. Рекомендация: удалить из индекса и добавить + `.task*.md` в `.gitignore`. Не функциональный дефект — на корректность + ORCH-061 не влияет. + +## Документация + +Обновлена в том же PR (golden source, AC-13) — соответствует требованию CLAUDE.md: + +- `docs/architecture/README.md` — раздел staging-гейта помечен ORCH-061 + + статус в футере. +- `docs/architecture/adr/adr-0009-staging-infra-tolerance.md` — сквозной ADR + заведён; `adr/README.md` обновлён. +- `docs/operations/STAGING_CHECK.md` — поведение C9a/C9b, флаг, INFRA-WAIVED, + `--strict`. +- `.openclaw/agents/deployer.md` — уточнён контракт exit0/INFRA-WAIVED (контракт + `staging_status: SUCCESS|FAILED` неизменён). +- `.env.example` — `ORCH_STAGING_INFRA_TOLERANCE_ENABLED` (канон, секреты не + коммитятся). +- `CHANGELOG.md` — запись ORCH-061. +- ADR per-work-item `docs/work-items/ORCH-061/06-adr/ADR-001-*.md` — присутствует. + +Документация полная и точная; расхождений с кодом не выявлено. From 39769bdf23f4fd1fe0e0d36174c2c37cadb725d0 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Sun, 7 Jun 2026 13:21:17 +0000 Subject: [PATCH 9/9] tester(ET): auto-commit from tester run_id=300 --- docs/work-items/ORCH-061/13-test-report.md | 85 ++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 docs/work-items/ORCH-061/13-test-report.md diff --git a/docs/work-items/ORCH-061/13-test-report.md b/docs/work-items/ORCH-061/13-test-report.md new file mode 100644 index 0000000..b5e039c --- /dev/null +++ b/docs/work-items/ORCH-061/13-test-report.md @@ -0,0 +1,85 @@ +--- +type: test-report +work_item_id: ORCH-061 +result: PASS +--- + +# Test Report — ORCH-061 + +BUG: устранение петли `deploy-staging → development` при self-hosting self-deploy. +Реализован Direction (б) из ADR-001: классификация staging-проверок на `REAL` +(fail-closed) и `SANDBOX_INFRA` (allowlist `{C9a, C9b}`, waivable) + толерантный, +но fail-closed вердикт (`src/staging_verdict.py`), kill-switch +`staging_infra_tolerance_enabled` (env `ORCH_STAGING_INFRA_TOLERANCE_ENABLED`). + +## Окружение +- Python: 3.12.13 +- pytest: 8.3.3 +- Дата: 2026-06-07T13:19Z +- Ветка: `feature/ORCH-061-bug-deploy-staging-development` +- Review verdict: APPROVED (12-review.md) + +## Smoke test API (prod 8500, read-only) +| Endpoint | Результат | +|----------|-----------| +| GET /health | HTTP 200 `{"status":"ok","service":"orchestrator"}` | +| GET /status | HTTP 200 (ORCH-061 в стадии `testing`) | +| GET /queue | HTTP 200 (counts/resilience/reconcile present) | + +> Прод-контейнер 8500 не перезапускался и не трогался (self-hosting safety, AC-12). + +## Результаты по тест-плану (04-test-plan.yaml) + +| TC ID | Описание | Тест | Результат | +|-------|----------|------|-----------| +| TC-01 | Корректный self-deploy: staging SUCCESS → advance к deploy, без отката | `test_stage_engine.py::test_tc01_healthy_self_deploy_advances_no_rollback` | PASS | +| TC-02 | Страховка ORCH-35: реальный FAIL → откат deploy-staging→development | `test_stage_engine.py::test_tc02_real_staging_failed_rolls_back` | PASS | +| TC-03 | Классификация REAL vs SANDBOX_INFRA (C9a/C9b отличимы) | `test_staging_check_b6.py::test_tc03_classify_infra_checks` (+ records/override/strict) | PASS | +| TC-04 | Падают только C9a/C9b → итог не-FAILED (нет ложного отката) | `test_qg_checks.py::test_tc04_only_infra_failures_waived_to_success` | PASS | +| TC-05 | Падает реальная pipeline-проверка → FAILED (fail-closed) | `test_qg_checks.py::test_tc05_any_real_failure_fails_closed` (+ `_even_alone`) | PASS | +| TC-06 | no-changes на action-стадии (deploy-staging/deploy) не есть недовыполнение | `test_launcher.py::test_tc06_deploy_staging_self_deploy_returns_note` / `test_tc06_deploy_self_deploy_returns_note` | PASS | +| TC-07 | regression-guard: на code-стадии (development) поведение прежнее | `test_launcher.py::test_tc07_development_stage_returns_none` | PASS | +| TC-08 | Не-self-hosting репо: check_staging_status остаётся (True, "N/A …") | `test_qg.py` (no-op N/A) | PASS | +| TC-09 | Kill-switch выкл → 1:1 прежнее строгое поведение, безопасный дефолт | `test_qg_checks.py::test_tc09_infra_failure_strict_mode_fails_closed` + `test_config.py::test_staging_infra_tolerance_*` | PASS | +| TC-10 | БАГ-8: deploy_status FAILED → откат deploy→development | `test_deploy_rollback.py` | PASS | +| TC-11 | Снапшот QG_CHECKS / STAGE_TRANSITIONS не изменён; frontmatter-контракты целы | `test_qg_registry_snapshot.py` | PASS | +| TC-12 | never-raise: вердикт-логика при мусоре → безопасный детерминированный FAILED | `test_qg_checks.py::test_tc12_compute_verdict_never_raises_on_garbage` + `test_stage_engine.py::test_tc12_retry_and_rollback_behavior_unchanged` | PASS | +| TC-13 | Сквозной self-deploy: deploy-staging→deploy→done без единого отката | `test_stage_engine.py::test_tc13_end_to_end_self_deploy_no_single_rollback` | PASS | +| TC-14 | Наблюдаемость: «зелёный с допущением» отличим от честного зелёного | `test_stage_engine.py::test_tc14_waived_green_distinguishable_from_honest_green` | PASS | + +Все 14 TC присутствуют и зелёные. + +## Сопоставление с критериями приёмки (03-acceptance-criteria.md) +| AC | Критерий | Покрытие | Статус | +|----|----------|----------|--------| +| AC-1 | Проход self-deploy без петли | TC-01, TC-13 | PASS | +| AC-2 | Инфра-FAIL (C9a/C9b) не откатывает | TC-03, TC-04 | PASS | +| AC-3 | Реальный провал staging откатывает | TC-02, TC-05 | PASS | +| AC-4 | no-changes на action-стадии ≠ недовыполнение | TC-06, TC-07 | PASS | +| AC-5 | БАГ-8: провал прод-деплоя откатывает | TC-10 | PASS | +| AC-6 | Условность self-hosting сохранена | TC-08 | PASS | +| AC-7 | Kill-switch возвращает прежнее поведение | TC-09 | PASS | +| AC-8 | Контракты не сломаны (реестр/frontmatter/exit-code) | TC-11 | PASS | +| AC-9 | Схема БД не меняется | миграций нет (флаг — конфиг) | PASS | +| AC-10 | never-raise | TC-12 | PASS | +| AC-11 | Наблюдаемость (INFRA-WAIVED / waived list) | TC-14 | PASS | +| AC-12 | Безопасность self-hosting (прод 8500 не трогается) | smoke + код пути | PASS | +| AC-13 | Документация обновлена (golden source) | подтверждено в 12-review.md | PASS | +| AC-14 | Регрессионные тесты зелёные | `pytest tests/ -q` → 670 passed | PASS | + +## Вывод pytest +``` +$ python -m pytest tests/ -v --tb=short +... +======================= 670 passed, 1 warning in 12.15s ======================== +``` +Единственный warning — PydanticDeprecatedSince20 (class-based Config в `src/config.py`), +не относится к ORCH-061, существовал ранее. + +## Итог +**PASS** — полный регресс зелёный (670 passed, 0 failed), все 14 TC из плана и все 14 +критериев приёмки выполнены. Страховка цела (реальный регресс staging и БАГ-8 +откатывают), условность self-hosting сохранена, kill-switch работает, never-raise +покрыт. Smoke API prod — 200, прод-контейнер не затронут. + +Задача готова к переходу на стадию **deploy-staging**.