Merge pull request 'ORCH-112: resilient-pull hygiene for dirty shared deploy-base (fix incident ORCH-111)' (#136) from feature/ORCH-112-bug-failed-cancelled-task-arti into main
Some checks failed
CI / test (push) Has been cancelled
Some checks failed
CI / test (push) Has been cancelled
This commit was merged in pull request #136.
This commit is contained in:
@@ -340,6 +340,15 @@ ORCH_DEPLOY_PROD_TARGET_IMAGE=orchestrator-orchestrator
|
||||
ORCH_DEPLOY_PROD_COMPOSE_PROFILE=
|
||||
ORCH_DEPLOY_PROD_PREV_IMAGE_FILE=.deploy-prev-image-prod
|
||||
|
||||
# ORCH-112: deploy-base checkout-hygiene (resilient-pull). The self-deploy hook
|
||||
# converges a DIRTY shared deploy-base to a clean, current origin/main BEFORE the
|
||||
# `git pull` (git fetch + reset --hard + a SCOPED `git clean -fd`, NEVER `-x`), so
|
||||
# manual/abandoned WIP left by a failed/cancelled task never blocks the deploy
|
||||
# (incident ORCH-111). False -> bare `git pull origin main` 1:1 as before ORCH-112.
|
||||
# Empty REPOS -> only the self-hosting repo (orchestrator).
|
||||
ORCH_CHECKOUT_HYGIENE_ENABLED=true
|
||||
ORCH_CHECKOUT_HYGIENE_REPOS=
|
||||
|
||||
# ORCH-058: staging-image provenance before the BUILD-ONCE prod retag (INV-FRESH).
|
||||
# Guarantees the staging image promoted to prod is the EXACT artefact rebuilt from the
|
||||
# validated commit — two layers, self-hosting only:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Work item: ORCH-110
|
||||
Work item: ORCH-112
|
||||
Repo: orchestrator
|
||||
Branch: feature/ORCH-110-bug-merge-gate-local-re-test-t
|
||||
Branch: feature/ORCH-112-bug-failed-cancelled-task-arti
|
||||
Stage: development
|
||||
@@ -3,6 +3,11 @@
|
||||
Формат: [Keep a Changelog](https://keepachangelog.com/). Записи — на смысловой PR/задачу.
|
||||
|
||||
## [Unreleased]
|
||||
- **Гигиена shared deploy-базы: устойчивый self-deploy `git pull` к грязному дереву** (ORCH-112, `fix`, bug→escalate full-cycle): устранён инцидент ORCH-111 — self-deploy падал на шаге `git pull origin main` хост-хука с `error: Your local changes to the following files would be overwritten by merge: src/config.py` (грязь от неуспешной/отменённой/брошенной задачи ORCH-104 в общем main checkout) → деплой вставал → ручное вмешательство (на self-hosting — групповой риск). Решение — **resilient-pull, встроенный в прод-deploy-хук** (`--deploy`): перед `git pull` хук при обнаружении грязи приводит deploy-базу к чистому актуальному `origin/main` (`git fetch` + `git reset --hard origin/main` + **скоупленный** `git clean -fd`). Аддитивно, под kill-switch, never-raise, скоуп self-hosting; `STAGE_TRANSITIONS` / реестр `QG_CHECKS` / семантика и имена `check_*` / machine-verdict-ключи / схема БД / exit-code-контракт хука (0/1/2, ORCH-036) — **байт-в-байт не тронуты** (это устойчивость deploy-пути, **не** Quality Gate и **не** стадия). ADR: `docs/work-items/ORCH-112/06-adr/ADR-001-deploy-base-checkout-hygiene.md`, сквозной `docs/architecture/adr/adr-0044-deploy-base-checkout-hygiene.md`.
|
||||
- **Leaf `src/checkout_hygiene.py` (новый, чистый never-raise):** по образцу `serial_gate`/`cancel`/`self_deploy` (импортирует только `config`, лениво `self_deploy`/`qg.checks`/`notifications`) — `applies(repo)` (kill-switch `checkout_hygiene_enabled` + скоуп `checkout_hygiene_repos`, пусто → self-hosting only, локально и ПЕРВЫМ), `hook_env(repo, work_item_id)` (env-префикс `CHECKOUT_HYGIENE=1 HYGIENE_REPORT=<host-path>`, инжектится в detached-команду хука только при `applies==True`, иначе `""` → голый pull 1:1), `read_report`/`alert_dirty` (наблюдаемость), `snapshot()` (read-only блок `GET /queue`).
|
||||
- **Хук-блок «2a. Resilient pull» (`scripts/orchestrator-deploy-hook.sh`):** между шагом «1. Capture PREV_IMG» и «2. Pull», под `if [[ "${CHECKOUT_HYGIENE:-0}" == "1" ]]`. **Сохранность (NFR-2, жёсткий контракт):** `git clean` — **только `-fd`, НИКОГДА `-x`** (иначе удалил бы gitignored `.env`/прод-секреты, `data/*.db`/БД, `build/`); явные `-e '.deploy-prev-image-*'` и `-e 'deploy-hook.log'` (untracked-но-НЕ-ignored — иначе сломался бы rollback `do_rollback`); sibling `<repos_dir>/.deploy-state-*`/`.merge-lease-*.json` (под родителем `$REPO`) и `.git/worktrees/*` (внутри `.git/`) — вне области `git clean` в `$REPO`. Каждый git-шаг — `|| log "...continuing"` (never-break): сбой гигиены не ухудшает исход относительно текущего голого pull; на чистой базе блок — no-op (happy-path и exit-коды байт-в-байт). `--build-staging` (build из worktree, без pull) не затронут.
|
||||
- **Сходимость после failed/cancelled (FR-2)** — этим же deploy-time self-heal (база сходится на следующем же self-deploy); `cancel_task` (ORCH-090) **не расширяется**, фоновый janitor **не вводится**. **Наблюдаемость (FR-4)** — хук пишет sentinel `hygiene` в deploy-state каталог; Phase-C finalizer (`stage_engine.run_deploy_finalizer`) читает (`read_report`) и шлёт Telegram-алерт (`alert_dirty`, кликабельный номер, best-effort, never-raise — сбой алерта не валит деплой).
|
||||
- **Флаги** (`config.py`, дефолт = боевое): `checkout_hygiene_enabled` (env `ORCH_CHECKOUT_HYGIENE_ENABLED`), `checkout_hygiene_repos` (env `ORCH_CHECKOUT_HYGIENE_REPOS`). Откат = `ORCH_CHECKOUT_HYGIENE_ENABLED=false` → деплой байт-в-байт до ORCH-112. Покрытие — `tests/test_deploy_checkout_hygiene.py` (TC-01…TC-10: шелл-симуляция реального хука во временном git-репо без сети/прода/ssh + unit; TC-01 — обязательный регресс ORCH-111: КРАСНЫЙ до фикса, ЗЕЛЁНЫЙ после).
|
||||
- **Job-reaper не реапит живой долго финализирующий монитор `deploy-staging`** (ORCH-113, `fix`, bug→escalate full-cycle): устранено расхождение состояния из инцидента ORCH-111 (deployer job 1914 / run_id 683). На ребре `deploy-staging → deploy` живой монитор (`launcher._monitor_agent`) штампит `agent_runs.finished_at`/`exit_code` **первым**, затем синхронно в своём потоке прогоняет тяжёлые edge-под-гейты (`security → merge-gate re-test → coverage → image-freshness`) — **минуты** — и лишь потом `_finalize_job`. Reaper Tier-2 меряет `finished_age_s` от `finished_at` (= начала финализации), поэтому по истечении `reaper_finalize_grace_s=300` трактовал живого долго финализирующего монитора как мёртвого и **независимо** повторял тот же тяжёлый advance: повторный re-test стал красным → ложный откат `deploy-staging → development` (+ ложный developer-retry) **параллельно** с тем, что исходный finalizer довёл deploy до SUCCESS и смержил PR — состояние раздвоилось. Аддитивно, под глобальным kill-switch, never-raise; `STAGE_TRANSITIONS`/`QG_CHECKS`/каждый `check_*`/machine-verdict ключи/схема БД — **байт-в-байт не тронуты**; `reaper_finalize_grace_s`/`reaper_max_running_s` и сквозной бюджет ORCH-065/109/110 (`5400 > Σ(gate-work)+grace`) сохранены; фикс не рестартит прод и не пушит `main`. ADR: `docs/work-items/ORCH-113/06-adr/ADR-001-reaper-finalizer-liveness-ownership.md`, сквозной `docs/architecture/adr/adr-0043-reaper-finalizer-liveness-ownership.md`.
|
||||
- **Leaf `src/finalizer_liveness.py` (новый, процесс-локальный реестр владения):** чистый never-raise модуль (паттерн `serial_gate`/`coverage_gate`, без сети/БД) — `mark(job_id, run_id, stage)` / `clear(job_id)` / `is_active(job_id)` / `snapshot()`; состояние `{job_id: {...}}` под `threading.Lock`. Авторитетно in-memory, т.к. монитор и reaper — daemon-**потоки одного** uvicorn-процесса (CMD без `--workers`) с общей SQLite-БД. Собственного TTL нет — ограничение по времени даёт Tier-3 backstop. `is_active` при ошибке → `False` (консервативно: не блокировать добивание).
|
||||
- **Эмиссия владения (`launcher._monitor_agent`):** `mark()` вызывается **сразу после** штампа `exit_code` (самый ранний момент Tier-2), хвост финализации вынесен в `_run_monitor_finalization` и обёрнут в `try/finally` с `clear()` в `finally` → исключение в потоке монитора гарантированно снимает владение, и реально мёртвый finalizer добивается. Маркер пишется **безусловно** (kill-switch гейтит только консультацию reaper, поэтому выключенный путь — байт-в-байт прежний). Хвост перенесён **дословно** (проверяется `git diff -w`: +49/−0, нулевое изменение логики).
|
||||
|
||||
40
CLAUDE.md
40
CLAUDE.md
@@ -283,6 +283,46 @@ INV-4 (никогда push/force-push `main`) и запрет рестарта
|
||||
`docs/work-items/ORCH-110/06-adr/ADR-001-merge-gate-retest-infra-tolerance-and-tree-kill.md`, сквозной
|
||||
`docs/architecture/adr/adr-0042-merge-gate-retest-infra-tolerance-and-tree-kill.md`.
|
||||
|
||||
## Гигиена shared deploy-базы: устойчивый self-deploy `git pull` (ORCH-112)
|
||||
Багфикс инцидента **ORCH-111** (bug → escalate full-cycle): прод-self-deploy падал на шаге
|
||||
`git pull origin main` хост-хука (`scripts/orchestrator-deploy-hook.sh`) с `error: Your local changes
|
||||
to the following files would be overwritten by merge: src/config.py` — грязь, оставленная
|
||||
неуспешной/отменённой/брошенной задачей ORCH-104 в **общем** main checkout (`settings.deploy_host_repo_path`).
|
||||
Деплой вставал → ручное вмешательство; на self-hosting (один прод-инстанс на все проекты) — групповой
|
||||
риск. **Инвариант (нормативно):** shared main checkout `<host_repos_dir>/<repo>` — **deploy/worktree-management
|
||||
база, НЕ редактируемый workspace** (агенты — worktree `git_worktree`, build — worktree-контекст, fallback'и
|
||||
гейтов — read-only `git show origin/main`); локальных правок там быть не должно. Решение — **resilient-pull,
|
||||
встроенный в хук** (`--deploy`): перед `git pull` хук при грязи приводит базу к чистому актуальному
|
||||
`origin/main` (`git fetch` + `git reset --hard origin/main` + **скоупленный** `git clean -fd`). Аддитивно,
|
||||
под kill-switch, never-raise, скоуп self-hosting; `STAGE_TRANSITIONS` / реестр `QG_CHECKS` / семантика и
|
||||
имена `check_*` / machine-verdict-ключи / схема БД / exit-code-контракт хука (0/1/2, ORCH-036) — **байт-в-байт
|
||||
не тронуты** (это устойчивость deploy-пути, **не** Quality Gate и **не** стадия).
|
||||
- **Leaf `src/checkout_hygiene.py` (чистый never-raise):** по образцу `serial_gate`/`cancel`/`self_deploy`
|
||||
(импортирует только `config`, лениво `self_deploy`/`qg.checks`/`notifications`) — `applies(repo)`
|
||||
(kill-switch `checkout_hygiene_enabled` + скоуп `checkout_hygiene_repos`, **пусто → self-hosting only**,
|
||||
локально и ПЕРВЫМ), `hook_env(repo, work_item_id)` (env-префикс `CHECKOUT_HYGIENE=1 HYGIENE_REPORT=<host-path>`,
|
||||
инжектится в detached-команду `self_deploy.build_deploy_command` только при `applies==True`, иначе `""` →
|
||||
хук видит `CHECKOUT_HYGIENE` неустановленным → голый `git pull` 1:1 до ORCH-112), `read_report`/`alert_dirty`
|
||||
(наблюдаемость), `snapshot()` (read-only блок `GET /queue`).
|
||||
- **Хук-блок «2a. Resilient pull»:** между шагом «1. Capture PREV_IMG» и «2. Pull», под
|
||||
`if [[ "${CHECKOUT_HYGIENE:-0}" == "1" ]]`. **Сохранность (NFR-2, жёсткий контракт):** `git clean` —
|
||||
**только `-fd`, НИКОГДА `-x`** (иначе удалил бы gitignored `.env`/прод-секреты, `data/*.db`/БД, `build/`);
|
||||
явные `-e '.deploy-prev-image-*'` и `-e 'deploy-hook.log'` (untracked-но-НЕ-ignored — иначе сломался бы
|
||||
rollback `do_rollback`); sibling `<repos_dir>/.deploy-state-*`/`.merge-lease-*.json` и `.git/worktrees/*` —
|
||||
вне области `git clean` в `$REPO`. Каждый git-шаг — `|| log "...continuing"` (never-break): сбой гигиены не
|
||||
ухудшает исход относительно голого pull; чистая база → no-op (happy-path/exit-коды байт-в-байт);
|
||||
`--build-staging` (build из worktree, без pull) не затронут.
|
||||
- **Сходимость после failed/cancelled (FR-2)** — этим же deploy-time self-heal (база сходится на следующем же
|
||||
self-deploy); `cancel_task` (ORCH-090) **не расширяется**, фоновый janitor **не вводится**.
|
||||
**Наблюдаемость (FR-4)** — хук пишет sentinel `hygiene`; Phase-C finalizer (`stage_engine.run_deploy_finalizer`)
|
||||
читает (`read_report`) и шлёт Telegram-алерт (`alert_dirty`, кликабельный номер, best-effort, never-raise).
|
||||
- **Флаги** (`config.py`, дефолт = боевое): `checkout_hygiene_enabled` (env `ORCH_CHECKOUT_HYGIENE_ENABLED`),
|
||||
`checkout_hygiene_repos` (env `ORCH_CHECKOUT_HYGIENE_REPOS`). Откат = `ORCH_CHECKOUT_HYGIENE_ENABLED=false` →
|
||||
деплой байт-в-байт до ORCH-112. Покрытие — `tests/test_deploy_checkout_hygiene.py` (шелл-симуляция реального
|
||||
хука во временном git-репо без сети/прода/ssh + unit; TC-01 — обязательный регресс ORCH-111: красный до
|
||||
фикса, зелёный после). Детали — `docs/work-items/ORCH-112/06-adr/ADR-001-deploy-base-checkout-hygiene.md`,
|
||||
сквозной `docs/architecture/adr/adr-0044-deploy-base-checkout-hygiene.md`.
|
||||
|
||||
## Машинный журнал уроков (ORCH-098)
|
||||
Шаг 1 («Фундамент», F2) эпика саморазвития: формализует свободнотекстовые «уроки» из `memory/` в
|
||||
**машинную структурированную таблицу отклонений конвейера** `lessons`, фундамент для будущих
|
||||
|
||||
@@ -742,6 +742,40 @@ sentinel-файлы (`<repos_dir>/.deploy-state-<repo>/<wi>/`), без мигр
|
||||
Подробнее: [adr-0007](adr/adr-0007-executable-self-deploy.md), детально —
|
||||
`docs/work-items/ORCH-036/06-adr/ADR-001-executable-self-deploy.md`.
|
||||
|
||||
#### Гигиена shared deploy-базы: устойчивый self-deploy `git pull` (ORCH-112 — design)
|
||||
**Инвариант (нормативно):** shared main checkout `<host_repos_dir>/<repo>`
|
||||
(= `settings.deploy_host_repo_path` = `/home/slin/repos/orchestrator`) — это
|
||||
**deploy/worktree-management база, НЕ редактируемый workspace.** Рабочие изменения туда
|
||||
**не пишутся** конвейером/агентами: агенты работают в worktree'ах `/repos/_wt/<repo>/<branch>`
|
||||
(`git_worktree.ensure_worktree`), `docker build` берёт контекст worktree
|
||||
(`image_freshness._host_worktree_path`), fallback'и гейтов на main clone — только чтение
|
||||
(`git show origin/main:...`). Локальные правки в deploy-базе по определению существовать не должны.
|
||||
|
||||
**Проблема (инцидент ORCH-111 от грязи ORCH-104):** хук `scripts/orchestrator-deploy-hook.sh`
|
||||
делал **голый** `git pull origin main` в `$REPO` без гигиены рабочего дерева → любая локальная правка
|
||||
tracked-файла (`src/config.py`) блокировала merge → деплой падал → ручное вмешательство (групповой риск
|
||||
self-hosting). **Решение** — **resilient-pull, встроенный в хук** (`--deploy`): перед `git pull` хук
|
||||
при обнаружении грязи приводит базу к чистому актуальному `origin/main`
|
||||
(`git fetch` + `git reset --hard origin/main` + **скоупленный** `git clean -fd`). Гейт — env
|
||||
`CHECKOUT_HYGIENE`, инжектится `self_deploy.build_deploy_command` когда новый чистый never-raise leaf
|
||||
`src/checkout_hygiene.py`-`applies(repo)` истинен (kill-switch `checkout_hygiene_enabled` + скоуп
|
||||
`checkout_hygiene_repos`, пусто → self-hosting only).
|
||||
- **Сохранность (NFR-2, жёсткий контракт):** `git clean` — **только `-fd`, НИКОГДА `-x`** (иначе
|
||||
удалит gitignored `.env`/прод-секреты, `data/*.db`/БД, `build/`); явные `-e '.deploy-prev-image-*'`
|
||||
и `-e 'deploy-hook.log'` (untracked-но-НЕ-ignored — иначе сломается rollback `do_rollback`).
|
||||
Sibling `<repos_dir>/.deploy-state-*` / `.merge-lease-*.json` (под родителем `$REPO`) и
|
||||
`.git/worktrees/*` (внутри `.git/`) — вне области `git clean` в `$REPO`.
|
||||
- **Сходимость после failed/cancelled** — этим же deploy-time self-heal (база сходится на следующем
|
||||
деплое); `cancel_task` (ORCH-090) **не расширяется**, фоновый janitor **не вводится**.
|
||||
- **Наблюдаемость** — хук пишет sentinel `hygiene` в deploy-state каталог; Phase-C finalizer читает и
|
||||
шлёт Telegram-алерт (кликабельный номер, best-effort, never-raise).
|
||||
- **Чистая база / kill-switch off** — голый fast-forward `git pull` байт-в-байт (happy-path без
|
||||
регресса); `--build-staging` (build из worktree, без pull) не затронут. `STAGE_TRANSITIONS` / реестр
|
||||
`QG_CHECKS` / `check_*` / machine-verdict / схема БД / exit-code-контракт хука (0/1/2) — не тронуты.
|
||||
|
||||
Детально — `docs/work-items/ORCH-112/06-adr/ADR-001-deploy-base-checkout-hygiene.md`, сквозной
|
||||
[adr-0044](adr/adr-0044-deploy-base-checkout-hygiene.md).
|
||||
|
||||
#### Выделенный статус-триггер прод-деплоя «Confirm Deploy» (ORCH-059 — реализовано)
|
||||
Перегрузка: один Plane-статус `Approved` служил И человеческим гейтом BRD на
|
||||
`analysis` (`check_analysis_approved`), И триггером Фазы B прод-деплоя на `deploy`
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
work_item: ORCH-112
|
||||
stage: architecture
|
||||
author_agent: architect
|
||||
status: proposed
|
||||
created_at: 2026-06-15
|
||||
model_used: claude-opus-4-8
|
||||
---
|
||||
|
||||
# adr-0044: Гигиена shared deploy-базы — устойчивый self-deploy `git pull`
|
||||
|
||||
Сквозное (cross-cutting) решение. Детальный per-work-item ADR —
|
||||
`docs/work-items/ORCH-112/06-adr/ADR-001-deploy-base-checkout-hygiene.md`.
|
||||
|
||||
## Статус
|
||||
Proposed (ORCH-112)
|
||||
|
||||
## Контекст (сквозной)
|
||||
|
||||
Глобальный путь прод-деплоя self-hosting (`deploy`-стадия, ORCH-036) исполняет хост-хук
|
||||
`scripts/orchestrator-deploy-hook.sh`, чей шаг «2. Pull latest code» — **голый** `git pull origin main`
|
||||
в shared main clone (`settings.deploy_host_repo_path`). Любая грязь рабочего дерева (модифицированный
|
||||
tracked-файл и/или untracked-остатки failed/cancelled/брошенной задачи) **блокирует** merge → деплой
|
||||
встаёт → ручное вмешательство. На self-hosting (один прод-инстанс на все проекты с общей БД/очередью)
|
||||
это **групповой риск**: залипший self-deploy орка останавливает обслуживание всех проектов
|
||||
(инцидент ORCH-111, грязь от ORCH-104).
|
||||
|
||||
## Решение (сквозное)
|
||||
|
||||
Вводится **resilient-pull, встроенный в прод-deploy-хук** (`--deploy`), + новый чистый never-raise
|
||||
leaf-компонент `src/checkout_hygiene.py`:
|
||||
|
||||
- **Хук** перед `git pull origin main` приводит грязную deploy-базу к чистому актуальному `origin/main`
|
||||
(`git fetch` + `git reset --hard origin/main` + **скоупленный** `git clean -fd`), **строго сохраняя**
|
||||
rollback/лог-артефакты. Гейт — env `CHECKOUT_HYGIENE`, инжектится `self_deploy.build_deploy_command`.
|
||||
- **Leaf** `checkout_hygiene` решает условность (`applies(repo)`: kill-switch `checkout_hygiene_enabled`
|
||||
+ скоуп `checkout_hygiene_repos`, пусто → self-hosting only), строит env-префикс, читает sentinel
|
||||
отчёта, шлёт Telegram-алерт. Образец `serial_gate`/`cancel`/`self_deploy`.
|
||||
- **Сходимость** базы после failed/cancelled (FR-2) — этим же deploy-time self-heal; `cancel_task`
|
||||
(ORCH-090) **не расширяется**, фоновый janitor **не вводится**.
|
||||
- **Наблюдаемость** — хук пишет sentinel `hygiene`, Phase-C finalizer читает и шлёт Telegram-алерт
|
||||
(best-effort, never-raise).
|
||||
- **Инвариант** «main checkout — deploy/worktree-management база, НЕ workspace» документируется
|
||||
(INFRA.md + architecture/README.md); de-facto энфорс — сам resilient-pull.
|
||||
|
||||
## Кросс-каттинг-инварианты (обязательны к соблюдению будущими задачами)
|
||||
|
||||
- **INV-HYGIENE-1 (никогда `-x`):** hygiene-`git clean` — только `git clean -fd`. `-x` удалил бы
|
||||
gitignored `.env` (прод-секреты) / `data/*.db` (БД прода) / `build/`. Анти-регресс — статический тест.
|
||||
- **INV-HYGIENE-2 (явные excludes):** `.deploy-prev-image-*` (rollback, `deploy_prod_prev_image_file`)
|
||||
и `deploy-hook.log` — untracked-но-НЕ-ignored → обязательны `-e`-исключения; их удаление сломало бы
|
||||
rollback.
|
||||
- **INV-HYGIENE-3 (скоуп = `$REPO`):** гигиена оперирует только рабочим деревом deploy-базы;
|
||||
sibling `<repos_dir>/.deploy-state-*` / `.merge-lease-*.json` и `.git/worktrees/*` — вне области.
|
||||
- **Self-hosting safety (NFR-1):** никогда не трогать `main` на remote, не force-push, не рестартить
|
||||
прод вне штатного гейта, не сносить worktree/ветки других активных задач.
|
||||
- **Нулевая регрессия (NFR-5):** `STAGE_TRANSITIONS` / реестр `QG_CHECKS` / семантика и имена `check_*` /
|
||||
machine-verdict ключи / схема БД / exit-code-контракт хука (0/1/2, ORCH-036) — байт-в-байт. Это
|
||||
устойчивость deploy-пути, **не** Quality Gate и **не** стадия.
|
||||
|
||||
## Связи
|
||||
- Дополняет: adr-0007 (executable self-deploy, ORCH-036), adr-0008 (image-freshness, ORCH-058).
|
||||
- Не нарушает: adr-0026 (STOP/cancel, ORCH-090) — каскад cancel не трогается.
|
||||
|
||||
## Откат
|
||||
`ORCH_CHECKOUT_HYGIENE_ENABLED=false` → прод-деплой байт-в-байт до ORCH-112 (голый `git pull origin main`).
|
||||
@@ -21,6 +21,14 @@
|
||||
/repos/<project> ← общий каталог репозиториев (host: /home/slin/repos)
|
||||
```
|
||||
|
||||
> **Инвариант deploy-базы (ORCH-112, нормативно).** Shared main checkout
|
||||
> `<host_repos_dir>/<repo>` (= `/home/slin/repos/orchestrator` == `/repos/orchestrator` в контейнере
|
||||
> через bind-mount == `settings.deploy_host_repo_path`) — это **deploy/worktree-management база, НЕ
|
||||
> редактируемый workspace.** Рабочие изменения туда **не пишутся** конвейером/агентами: агенты —
|
||||
> worktree `/repos/_wt/<repo>/<branch>` (`git_worktree`), `docker build` — worktree-контекст,
|
||||
> fallback'и гейтов — read-only `git show origin/main`. Self-deploy `git pull` устойчив к грязной
|
||||
> базе (resilient-pull, см. self-hosting-страховки ниже).
|
||||
|
||||
## Контейнеры
|
||||
|
||||
| Контейнер | Роль | Порт | env_file | БД (хост) | Старт |
|
||||
@@ -220,6 +228,7 @@ watchdog'а: **watchdog сигналит, pruner убирает**.
|
||||
**Страховки:**
|
||||
- Стадия `deploy-staging` (порт 8501) — обязательный гейт перед прод-деплоем орка. Прод-деплой недостижим, пока staging-гейт не зелёный (см. `STAGING.md`, ORCH-35). Гейт условный: реален только для self-hosting (repo=orchestrator), для остальных проектов — no-op.
|
||||
- **Свежесть staging-образа (ORCH-058):** на ребре `deploy-staging → deploy` (ПОСЛЕ merge-gate, ДО Phase A) QG-под-чек `check_staging_image_fresh` пересобирает staging-образ из валидированного коммита и пересоздаёт 8501 (Strategy A), а хук перед build-once retag fail-closed сверяет OCI-лейбл `revision` с `EXPECTED_REVISION` (Strategy B). Гарантирует: в прод промоутится РОВНО провалидированный артефакт (инцидент LESSONS_ORCH-036 п.4 — тихий промоут устаревшего образа). Сборки/recreate — ТОЛЬКО staging (8501); FAIL → откат на `development`. Условный: реален только для self-hosting.
|
||||
- **Гигиена shared deploy-базы (ORCH-112):** self-deploy `git pull origin main` устойчив к грязному рабочему дереву deploy-базы (модифицированные tracked + untracked-остатки failed/cancelled/брошенных задач). Хук `--deploy` перед pull приводит базу к чистому `origin/main` (resilient-pull: `git fetch` + `git reset --hard origin/main` + `git clean -fd`), **строго сохраняя** rollback-снимки `.deploy-prev-image-*`, `deploy-hook.log`, gitignored `.env`/`data/`/`*.db` (НИКОГДА `-x`!), sibling `.deploy-state-*`/`.merge-lease-*.json`, `.git/worktrees/*`. Гейт — kill-switch `ORCH_CHECKOUT_HYGIENE_ENABLED` (дефолт `True`; off → голый pull 1:1); скоуп `ORCH_CHECKOUT_HYGIENE_REPOS` (пусто → self-hosting only). Грязь базы детектируется → лог + Telegram-алерт (Phase-C finalizer). Решает инцидент ORCH-111 (грязь ORCH-104 заблокировала `git pull`). Детально — `docs/work-items/ORCH-112/06-adr/ADR-001`, сквозной adr-0044.
|
||||
|
||||
**Правила для агентов при задачах ORCH:**
|
||||
1. НЕ перезапускать / не ронять прод-контейнер `orchestrator` в рамках задачи.
|
||||
|
||||
7
docs/work-items/ORCH-112/00-business-request.md
Normal file
7
docs/work-items/ORCH-112/00-business-request.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Business Request: BUG: failed/cancelled task artifacts must be cleaned from shared checkout
|
||||
|
||||
Work Item ID: ORCH-112
|
||||
|
||||
## Description
|
||||
|
||||
TBD
|
||||
169
docs/work-items/ORCH-112/01-brd.md
Normal file
169
docs/work-items/ORCH-112/01-brd.md
Normal file
@@ -0,0 +1,169 @@
|
||||
---
|
||||
work_item: ORCH-112
|
||||
stage: analysis
|
||||
author_agent: analyst
|
||||
status: ready-for-review
|
||||
created_at: 2026-06-15
|
||||
model_used: claude-opus-4-8
|
||||
escalate: full-cycle
|
||||
---
|
||||
|
||||
# 01 — BRD / Bug-report: ORCH-112 — failed/cancelled task artifacts must be cleaned from shared checkout
|
||||
|
||||
Work Item: **ORCH-112** · Repo: **orchestrator** · Стадия: analysis · Трек: **Bug → эскалация в full-cycle**
|
||||
|
||||
> ⚠️ **`escalate: full-cycle` (ADR-001 D5 ORCH-019).** Баг помечен `Bug`, но по сути это
|
||||
> **архитектурный + safety-critical (self-hosting)** дефект: правка лежит в самом опасном пути
|
||||
> прод-деплоя (хост-хук, прямо перед рестартом прод-контейнера) и требует **решения о политике
|
||||
> жизненного цикла** shared checkout (ADR). Поэтому выпускается **полный** analysis-пакет, а не
|
||||
> облегчённый bug-пакет. Оператор снимает багфикс-трек: `POST /bug-fast-track/escalate?work_item=ORCH-112`
|
||||
> → задача пойдёт через стадию `architecture` (architect выпустит ADR для политики cleanup/изоляции).
|
||||
|
||||
---
|
||||
|
||||
## 1. Бизнес-контекст и проблема
|
||||
|
||||
### Симптом (наблюдаемое)
|
||||
Self-deploy задачи **ORCH-111** упал на шаге `git pull origin main` хост-хука деплоя с ошибкой:
|
||||
```
|
||||
error: Your local changes to the following files would be overwritten by merge:
|
||||
src/config.py
|
||||
Please commit your changes or stash them before you merge.
|
||||
```
|
||||
Деплой прерван, конвейер потребовал **ручного вмешательства оператора** (на self-hosting это
|
||||
групповой риск — встаёт деплой и всех других проектов).
|
||||
|
||||
### Причина симптома (установленный факт)
|
||||
В **общем (shared) checkout** `/home/slin/repos/orchestrator` оставались грязные файлы от
|
||||
ранее **неуспешной/отменённой/перезапущенной задачи ORCH-104** (тема Lite installer):
|
||||
- модифицированный tracked-файл: `src/config.py`;
|
||||
- модифицированный/untracked: `docs/deployment/LITE_SETUP.md`;
|
||||
- untracked: `scripts/install_lite.py`, `tests/test_install_lite.py`,
|
||||
`docs/deployment/lite-install.example.yaml`.
|
||||
|
||||
Через несколько дней эти остатки **заблокировали** `git pull` другой задачи (ORCH-111).
|
||||
|
||||
### Локализация (анализ — куда смотреть архитектору/разработчику)
|
||||
|
||||
**Установленный факт о топологии (CLAUDE.md / `docs/architecture/README.md`):**
|
||||
`/home/slin/repos/orchestrator` (хост) == `/repos/orchestrator` (контейнер, bind-mount) ==
|
||||
**main clone** (`settings.repos_dir/<repo>` = `settings.deploy_host_repo_path`). Это **deploy-база
|
||||
и база управления worktree'ами**, а НЕ рабочая копия агента.
|
||||
|
||||
1. **Первичный дефект — нерезистентный `git pull`.**
|
||||
`scripts/orchestrator-deploy-hook.sh:224-226` делает `cd "$REPO"` (= deploy-база) и
|
||||
**голый** `git pull origin main` **без гигиены рабочего дерева**. Любая локальная правка
|
||||
tracked-файла блокирует merge → деплой падает. Проверено: во всём `src/`+`scripts/` **нет ни
|
||||
одного** `git reset --hard` / `git clean` / `git stash` для приведения базы к чистому состоянию.
|
||||
Shared checkout трактуется как «всегда чистый», что не гарантировано.
|
||||
|
||||
2. **Невыполненный/неэнфорснутый инвариант + отсутствие «дворника».**
|
||||
Нормальный конвейер **не пишет** в рабочее дерево main clone: агенты работают в изолированных
|
||||
worktree'ах `/repos/_wt/<repo>/<branch>` (`git_worktree.ensure_worktree`); `docker build`
|
||||
использует контекст **worktree** (`image_freshness._host_worktree_path`), не main clone;
|
||||
fallback'и гейтов на main clone — **только чтение** (`git show origin/main:...`,
|
||||
`qg/checks.py:451`, `coverage_gate.py:297`, `stage_engine.py:145`). Поэтому грязь ORCH-104
|
||||
почти наверняка — **ручной/брошенный WIP** в shared checkout во время инцидента ORCH-104
|
||||
(косвенное подтверждение: файлы `install_lite.py`/`test_install_lite.py`/`lite-install.example.yaml`
|
||||
**никогда не существовали в git-истории** — закоммиченный артефакт ORCH-104 это
|
||||
`scripts/setup_lite.py`, commit `e2cf883`). Вне зависимости от источника: **нет механизма**,
|
||||
который детектирует/чистит грязную базу и **нет задокументированного/энфорснутого инварианта**
|
||||
«main checkout — неизменяемая deploy-база, не workspace».
|
||||
|
||||
3. **`cancel_task` чистит worktree + remote-ветку, но НЕ shared checkout.**
|
||||
`stage_engine.cancel_task` (шаг 3d, строки ~2330-2343): `remove_worktree(repo, branch)` +
|
||||
`gitea.delete_remote_branch(repo, branch)`. Это корректно (конвейер в main clone не пишет), но
|
||||
означает **нулевое покрытие** случая «грязная deploy-база» в каскадах failed/cancelled.
|
||||
|
||||
**Вывод:** даже если первопричина грязи — ручное действие, устойчивость должна быть на стороне
|
||||
системы: deploy-база обязана **самовосстанавливаться** в чистый `origin/main` перед pull, а
|
||||
политика жизненного цикла — гарантировать, что остатки failed/cancelled задач не клинят будущие
|
||||
операции.
|
||||
|
||||
## 2. Объём (scope)
|
||||
|
||||
### В объёме
|
||||
- Сделать self-deploy `git pull origin main` (shared deploy-база) **устойчивым к грязному рабочему
|
||||
дереву** — приведение базы к чистому `origin/main` **автономно**, без ручного вмешательства.
|
||||
- Гарантировать, что после **failed / cancelled / брошенной** задачи в shared checkout не остаётся
|
||||
рабочих остатков, способных заблокировать будущий деплой/операцию (сходимость базы к чистому
|
||||
`origin/main`).
|
||||
- Задокументировать (и где осуществимо — мягко энфорснуть/гардить) инвариант
|
||||
«shared main checkout — deploy/worktree-management база, НЕ редактируемый workspace».
|
||||
- Наблюдаемость: лог + Telegram-алерт, когда deploy-база найдена грязной и автоочищена (или отказ).
|
||||
|
||||
### Вне объёма
|
||||
- ❌ Запрет/контроль ручных операций оператора в shared checkout (вне технической власти системы;
|
||||
закрываем устойчивостью, а не запретом).
|
||||
- ❌ Изменение модели worktree per-task (`git_worktree`, ORCH-2) — она корректна и не трогается.
|
||||
- ❌ Любое изменение `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / machine-verdict ключей / схемы БД.
|
||||
- ❌ Изменение поведения деплоя на чистой базе (happy-path должен остаться байт-в-байт).
|
||||
- ❌ Выбор конкретного механизма (reset --hard vs janitor vs guard) — это **зона архитектора** (ADR).
|
||||
|
||||
## 3. Заинтересованные стороны
|
||||
- **Заказчик/оператор (Слава)** — страдает от ручного разруливания залипших деплоев; принимает результат.
|
||||
- **Self-hosting конвейер orchestrator** — прямой потребитель (надёжность прод-деплоя).
|
||||
- **Все проекты на общем инстансе (enduro-trails)** — косвенно: залипший self-deploy орка
|
||||
останавливает обслуживание их задач.
|
||||
|
||||
## 4. Бизнес-требования (BR)
|
||||
- **BR-1** — Грязное рабочее дерево shared deploy-базы (модифицированные tracked-файлы и/или
|
||||
untracked-файлы) **НЕ должно блокировать** self-deploy `git pull origin main`: деплой обязан
|
||||
привести базу к чистому, актуальному `origin/main` **без ручного вмешательства**.
|
||||
- **BR-2** — После failed / cancelled / брошенной задачи в shared checkout **не должно оставаться**
|
||||
рабочих остатков этой задачи, способных заблокировать будущий деплой/git-операцию; база
|
||||
**сходится** к чистому `origin/main`.
|
||||
- **BR-3** — Инвариант «shared main checkout (`<host_repos_dir>/<repo>`) — deploy/worktree-management
|
||||
база, НЕ workspace» должен быть **задокументирован** (`docs/operations/INFRA.md` +
|
||||
`docs/architecture/README.md`) и, где осуществимо, **энфорснут/гардирован**; конвейер/агенты
|
||||
**никогда** не пишут рабочие изменения в main clone (верифицировать, что это так).
|
||||
- **BR-4** — **Наблюдаемость:** обнаружение грязной базы и факт автоочистки (или отказ) должны
|
||||
логироваться и алертиться (Telegram, кликабельный номер) — оператор видит, что гигиена сработала.
|
||||
- **BR-5** — На **чистой** базе поведение деплоя — **байт-в-байт прежнее** (обычный fast-forward
|
||||
`git pull`); никакого регресса happy-path.
|
||||
|
||||
## 5. Нефункциональные требования (NFR)
|
||||
- **NFR-1 (self-hosting safety)** — гигиена **никогда** не трогает ветку `main` на remote, не делает
|
||||
force-push, не рестартит прод-контейнер вне штатного гейта, не удаляет worktree/ветки **других
|
||||
активных** задач. Оперирует **только** настроенным путём deploy-базы.
|
||||
- **NFR-2 (сохранность deploy-состояния)** — автоочистка **не должна** удалять артефакты, легитимно
|
||||
живущие под `$REPO`/рядом: rollback-снимки `$REPO/.deploy-prev-image-*`
|
||||
(`deploy_prod_prev_image_file`), `deploy-hook.log`, sibling-состояния
|
||||
`<repos_dir>/.deploy-state-*` / `.merge-lease-*.json`, и админ-записи worktree в `.git/worktrees`.
|
||||
(Наивный `git clean -xfd` в `$REPO` уничтожил бы `.deploy-prev-image-*` и сломал rollback — это
|
||||
**жёсткое ограничение** для архитектора/разработчика.)
|
||||
- **NFR-3 (обратимость / kill-switch)** — новое поведение под флагом; выключенный флаг → деплой
|
||||
байт-в-байт как до ORCH-112 (голый `git pull origin main`).
|
||||
- **NFR-4 (надёжность)** — never-raise / fail-safe (по образцу leaf'ов `serial_gate`/`cancel`);
|
||||
идемпотентность; restart-safe; сбой гигиены не должен маскировать или ухудшать исход деплоя сверх
|
||||
текущего.
|
||||
- **NFR-5 (нулевая регрессия конвейера)** — `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` /
|
||||
machine-verdict ключи / схема БД / exit-code-контракт хука (0/1/2, ORCH-036) — **байт-в-байт**.
|
||||
- **NFR-6 (область)** — изменение скоупится на self-hosting (`orchestrator`); поведение для прочих
|
||||
репо/синхронного деплоя агентом — не ухудшается.
|
||||
|
||||
## 6. Допущения и ограничения
|
||||
- Shared checkout и хост-хук физически разделяют один путь с контейнером через bind-mount
|
||||
(`repos_dir`↔`host_repos_dir`); хук исполняется на **хосте** по ssh (ORCH-036, detached).
|
||||
- Build-once путь (`SOURCE_IMAGE` retag) **не** зависит от содержимого рабочего дерева main clone —
|
||||
прод получает ровно staging-валидированный образ; значит дискард рабочего дерева base перед pull
|
||||
**безопасен для деплоимого артефакта**. (`--build-staging` собирается из **worktree**, не из main —
|
||||
отдельный контур.)
|
||||
- Источник истины кода — `origin/main`; локальные правки в deploy-базе **по определению** не должны
|
||||
существовать (это deploy-база, а не место работы).
|
||||
- Конкретный механизм (resilient pull через reset+clean со скоуп-исключениями / активный janitor /
|
||||
guard инварианта / комбинация) — **открытый вопрос для архитектуры**, решается в `06-adr/`.
|
||||
|
||||
## 7. Критерии успеха
|
||||
Self-deploy успешно выполняет `git pull` на ранее грязной shared-базе **без ручного вмешательства**;
|
||||
deploy-база сходится к чистому `origin/main`; rollback-состояние и sibling-артефакты сохранены;
|
||||
happy-path и весь конвейер — без регресса; обязательный регресс-тест **красный до фикса, зелёный
|
||||
после**. Детальные PASS/FAIL — `03-acceptance-criteria.md`.
|
||||
|
||||
## 8. Риски
|
||||
- Деструктивная гигиена (`reset --hard`/`clean`) в **прод-deploy-базе** рядом с рестартом прода —
|
||||
ошибка скоупа может удалить rollback-state/логи (см. NFR-2) → ADR обязателен.
|
||||
- Маскировка реальной первопричины: если в будущем какой-то код **начнёт** писать в main clone,
|
||||
«тихая автоочистка» это скроет → нужна наблюдаемость (BR-4).
|
||||
- Кросс-каттинг с ORCH-036 (self-deploy), ORCH-058 (image-freshness/provenance), ORCH-090 (cancel),
|
||||
ORCH-2 (worktree-модель). Детали/митигации — `10-tech-risks.md` (заполняет архитектор).
|
||||
110
docs/work-items/ORCH-112/02-trz.md
Normal file
110
docs/work-items/ORCH-112/02-trz.md
Normal file
@@ -0,0 +1,110 @@
|
||||
---
|
||||
work_item: ORCH-112
|
||||
stage: analysis
|
||||
author_agent: analyst
|
||||
status: ready-for-review
|
||||
created_at: 2026-06-15
|
||||
model_used: claude-opus-4-8
|
||||
---
|
||||
|
||||
# 02 — ТЗ (TRZ): ORCH-112 — failed/cancelled task artifacts must be cleaned from shared checkout
|
||||
|
||||
Work Item: **ORCH-112** · Repo: **orchestrator** · Стадия: analysis
|
||||
|
||||
> ТЗ описывает **требования и ограничения к реализации**, выведенные из BRD и фактического кода.
|
||||
> Архитектурное **решение** (какой механизм гигиены/изоляции выбрать) — задача архитектора (`06-adr/`),
|
||||
> т.к. задача эскалирована в full-cycle (`01-brd.md` → `escalate: full-cycle`).
|
||||
|
||||
## 1. Сводка изменения
|
||||
Сделать self-deploy устойчивым к **грязной shared deploy-базе** и гарантировать сходимость базы к
|
||||
чистому `origin/main` после failed/cancelled/брошенных задач. Корень симптома — голый
|
||||
`git pull origin main` в `scripts/orchestrator-deploy-hook.sh` (строка 226), исполняемый в
|
||||
`$REPO` (= `settings.deploy_host_repo_path` = main clone), который падает при любой локальной правке
|
||||
tracked-файла. Требуется: (а) приведение deploy-базы к чистому `origin/main` перед/в момент pull
|
||||
**без ручного вмешательства**, со строгим сохранением deploy-rollback-состояния; (б) документирование
|
||||
+ (по возможности) энфорс инварианта «main checkout — deploy-база, не workspace»; (в) наблюдаемость.
|
||||
|
||||
## 2. Задействованные модули / пути
|
||||
| Путь | Действие | Примечание |
|
||||
|------|----------|-----------|
|
||||
| `scripts/orchestrator-deploy-hook.sh` | изменить | строки 224-226: голый `git pull origin main` в `$REPO` — точка отказа (FR-1) |
|
||||
| `src/self_deploy.py` | возможно изменить | `build_deploy_command` / `initiate_deploy` / `rebuild_staging_image` строят инвокацию хука — возможная точка передачи гигиены/флага (решает архитектор) |
|
||||
| `src/stage_engine.py` | возможно изменить | `cancel_task` (шаг 3d, ~2330-2343) — каскад cancel; расширение гигиены на shared-базу (FR-2, если выбран этот путь) |
|
||||
| `src/git_worktree.py` | возможно изменить | модель main clone ↔ worktree; возможный helper приведения базы к чистоте / верификация инварианта (BR-3) |
|
||||
| `src/config.py` | изменить | новый kill-switch + флаги области (FR-5) |
|
||||
| `src/<new_leaf>.py` (напр. `checkout_hygiene.py`) | возможно создать | чистый never-raise leaf политики гигиены (по образцу `serial_gate`/`cancel`) — **создавать ли** решает архитектор |
|
||||
| `docs/operations/INFRA.md` | изменить | инвариант «shared checkout — deploy-база, не workspace» (BR-3) |
|
||||
| `docs/architecture/README.md` | изменить | описать политику гигиены/жизненного цикла deploy-базы |
|
||||
| `CHANGELOG.md`, `CLAUDE.md` | изменить | правило «docs = golden source» (CLAUDE.md §2) |
|
||||
| `tests/test_<...>.py` | создать | регресс + покрытие (см. `04-test-plan.yaml`) |
|
||||
|
||||
## 3. Функциональные требования
|
||||
|
||||
### FR-1 — Устойчивый self-deploy `git pull` (BR-1, BR-5)
|
||||
- На пути self-deploy (`scripts/orchestrator-deploy-hook.sh`, шаг «2. Pull latest code»)
|
||||
`git pull origin main` **не должен падать** из-за грязного рабочего дерева `$REPO`.
|
||||
- Перед обновлением база приводится к чистому, актуальному `origin/main` (приведение tracked- и
|
||||
untracked-изменений к состоянию `origin/main`), **с сохранением** артефактов из NFR-2.
|
||||
- На **уже чистой** базе результат — обычный fast-forward; наблюдаемое поведение и exit-коды
|
||||
(0/1/2, ORCH-036) — **байт-в-байт прежние** (BR-5).
|
||||
- Контракт: never-break — сбой шага гигиены не должен ухудшать исход относительно текущего голого
|
||||
pull (fail-safe).
|
||||
|
||||
### FR-2 — Сходимость shared-базы после failed/cancelled/брошенной задачи (BR-2)
|
||||
- После терминации задачи (`failed` job-исход / `cancelled` через STOP / брошенный WIP) в shared
|
||||
checkout **не остаётся** рабочих остатков, способных заблокировать будущий деплой/git-операцию.
|
||||
- Допустимая трактовка «сходимости» (на выбор архитектора, **не** прескриптивно здесь): автоочистка
|
||||
непосредственно в self-deploy перед pull (FR-1) **и/или** активный «дворник», приводящий
|
||||
`<host_repos_dir>/<repo>` к чистому `origin/main`.
|
||||
- Каскад `cancel_task` (ORCH-090) уже чистит **worktree + remote-ветку**; расширение на shared-базу
|
||||
(если выбрано) делается тем же never-raise best-effort способом.
|
||||
|
||||
### FR-3 — Инвариант deploy-базы (BR-3)
|
||||
- Задокументировать: `<host_repos_dir>/<repo>` — deploy/worktree-management база; рабочие изменения
|
||||
туда **не пишутся** конвейером/агентами (агенты — worktree `git_worktree`; build — worktree-контекст;
|
||||
fallback'и гейтов — read-only `git show origin/main`).
|
||||
- Верифицировать, что текущий код этот инвариант соблюдает (анализ ORCH-112: соблюдает; единственные
|
||||
обращения к main clone — read-only/fetch/worktree-управление). Где осуществимо — добавить
|
||||
лёгкий guard/проверку (решает архитектор), **без** изменения горячих путей.
|
||||
|
||||
### FR-4 — Наблюдаемость (BR-4)
|
||||
- Обнаружение грязной deploy-базы и факт автоочистки (число/имена сброшенных путей) или **отказ**
|
||||
гигиены — лог (структурная запись) + Telegram-алерт (`send_telegram`, кликабельный номер задачи,
|
||||
best-effort, never-raise). Опционально — read-only снапшот в `GET /queue` (решает архитектор).
|
||||
|
||||
### FR-5 — Условность / kill-switch (BR-5, NFR-3, NFR-6)
|
||||
- Новое поведение под **kill-switch** (env `ORCH_*`, по образцу `serial_gate_enabled`/`stop_status_enabled`);
|
||||
выключенный флаг → деплой байт-в-байт прежний (голый `git pull origin main`).
|
||||
- Область — self-hosting (`orchestrator`); прочие репо/синхронный деплой агентом — не ухудшаются.
|
||||
- `applies(repo)` (локальный, без сети) проверяется первым.
|
||||
|
||||
## 4. Изменения API
|
||||
**Нет** обязательных. Опционально (на усмотрение архитектора) — read-only блок (напр. `checkout_hygiene`)
|
||||
в существующем `GET /queue` для наблюдаемости. Новых управляющих эндпоинтов не требуется.
|
||||
|
||||
## 5. Изменения схемы БД
|
||||
**Нет.** Состояние гигиены, если нужно, — in-memory / sentinel-файлы (паттерн `self_deploy`/`merge_gate`),
|
||||
без миграции БД. Аддитивная таблица не требуется.
|
||||
|
||||
## 6. Требования к новым/изменённым QG checks
|
||||
**Нет.** Это **не** Quality Gate и не стадия — это устойчивость deploy-пути и политика гигиены.
|
||||
`STAGE_TRANSITIONS` / реестр `QG_CHECKS` / семантика и имена `check_*` / machine-verdict ключи
|
||||
(`deploy_status:`/`staging_status:`/…) — **байт-в-байт не тронуты** (NFR-5).
|
||||
|
||||
## 7. Совместимость / регресс
|
||||
- **Обратная совместимость:** kill-switch off → голый `git pull origin main` (1:1 до ORCH-112);
|
||||
чистая база → fast-forward без изменений (BR-5).
|
||||
- **Область раската:** self-hosting `orchestrator`; enduro/прочие — нулевая регрессия.
|
||||
- **Обратимость:** выключение флага мгновенно возвращает прежнее поведение.
|
||||
- **Сохранность (жёсткое ограничение, NFR-2):** гигиена **не удаляет** `$REPO/.deploy-prev-image-*`
|
||||
(rollback), `deploy-hook.log`, sibling `<repos_dir>/.deploy-state-*` / `.merge-lease-*.json`,
|
||||
админ-записи `.git/worktrees`. Любой `clean`-скоуп обязан их исключать.
|
||||
- **Self-hosting инварианты (NFR-1):** никогда не трогать `main` на remote, не force-push, не
|
||||
рестартить прод вне гейта, не сносить worktree/ветки других активных задач, оперировать только
|
||||
настроенным путём deploy-базы. Exit-code-контракт хука (0/1/2) сохранён.
|
||||
- **Артефакты pipeline:** создаются/обновляются обычные docs work item (`01..04` этой задачи,
|
||||
`06-adr/` на стадии architecture после эскалации, `14-deploy-log.md` при деплое). Новых
|
||||
pipeline-артефактов задача не вводит.
|
||||
- **Трассировка (CLAUDE.md §9 / ORCH-078):** правки маркированных блоков (ORCH-036 self-deploy,
|
||||
ORCH-058 image-freshness, ORCH-090 cancel) — сверять с их `06-adr/` перед изменением, инварианты
|
||||
не ломать.
|
||||
128
docs/work-items/ORCH-112/03-acceptance-criteria.md
Normal file
128
docs/work-items/ORCH-112/03-acceptance-criteria.md
Normal file
@@ -0,0 +1,128 @@
|
||||
---
|
||||
work_item: ORCH-112
|
||||
stage: analysis
|
||||
author_agent: analyst
|
||||
status: ready-for-review
|
||||
created_at: 2026-06-15
|
||||
model_used: claude-opus-4-8
|
||||
---
|
||||
|
||||
# 03 — Критерии приёмки (Acceptance Criteria): ORCH-112 — failed/cancelled task artifacts must be cleaned from shared checkout
|
||||
|
||||
Work Item: **ORCH-112** · Repo: **orchestrator** · Стадия: analysis
|
||||
|
||||
Формат: каждый критерий имеет **PASS** (что должно быть истинно для приёмки) и **FAIL**
|
||||
(что считается провалом). Reviewer/CI проверяют их буквально по файлам репозитория и тестам.
|
||||
|
||||
---
|
||||
|
||||
## AC-1 — Грязная tracked-правка не блокирует self-deploy pull (регресс ORCH-104/ORCH-111)
|
||||
|
||||
**Условие:** shared deploy-база имеет локальную модификацию tracked-файла (напр. `src/config.py`),
|
||||
self-deploy выполняет шаг pull.
|
||||
- **PASS:** база приводится к чистому актуальному `origin/main` без ручного вмешательства; шаг pull
|
||||
не возвращает «local changes would be overwritten by merge»; деплой продолжается; есть тест,
|
||||
воспроизводящий точный сценарий ORCH-111 (**красный до фикса, зелёный после**).
|
||||
- **FAIL:** pull/деплой падает на грязной tracked-правке; или сценарий не покрыт тестом.
|
||||
|
||||
---
|
||||
|
||||
## AC-2 — Untracked WIP-файлы не блокируют и не «протекают» в деплой
|
||||
|
||||
**Условие:** в shared-базе лежат untracked-файлы failed/брошенной задачи (напр.
|
||||
`scripts/install_lite.py`, `tests/test_install_lite.py`, `docs/deployment/lite-install.example.yaml`).
|
||||
- **PASS:** база сходится к чистому `origin/main`; untracked-остатки не блокируют операцию и не
|
||||
попадают в деплоимый/собираемый артефакт.
|
||||
- **FAIL:** untracked-остатки блокируют операцию либо остаются и клинят будущий деплой.
|
||||
|
||||
---
|
||||
|
||||
## AC-3 — Сохранность deploy-rollback-состояния и sibling-артефактов (жёсткое ограничение)
|
||||
|
||||
**Условие:** в `$REPO`/рядом присутствуют `.deploy-prev-image-*` (rollback), `deploy-hook.log`,
|
||||
`<repos_dir>/.deploy-state-*`, `.merge-lease-*.json`, `.git/worktrees/*`.
|
||||
- **PASS:** после гигиены **все** перечисленные артефакты на месте; rollback по
|
||||
`.deploy-prev-image-*` остаётся работоспособным; есть тест, доказывающий их неудаление.
|
||||
- **FAIL:** гигиена удаляет хотя бы один из этих артефактов (особенно `.deploy-prev-image-*`).
|
||||
|
||||
---
|
||||
|
||||
## AC-4 — Happy-path без регресса (чистая база)
|
||||
|
||||
**Условие:** shared-база чистая, self-deploy выполняет pull.
|
||||
- **PASS:** поведение и exit-коды (0/1/2, ORCH-036) — байт-в-байт прежние (обычный fast-forward);
|
||||
полный `pytest tests/ -q` зелёный.
|
||||
- **FAIL:** изменилось наблюдаемое поведение/exit-код на чистой базе, либо красный регресс.
|
||||
|
||||
---
|
||||
|
||||
## AC-5 — Self-hosting safety
|
||||
|
||||
**Условие:** исполнение пути гигиены/деплоя.
|
||||
- **PASS:** нет операций над веткой `main` на remote, нет force-push, нет рестарта прод-контейнера
|
||||
вне штатного гейта, нет удаления worktree/веток других активных задач; операции строго в пределах
|
||||
настроенного пути deploy-базы; тест/анализ это подтверждает.
|
||||
- **FAIL:** любое из перечисленных нарушений присутствует или возможно.
|
||||
|
||||
---
|
||||
|
||||
## AC-6 — Kill-switch и обратимость
|
||||
|
||||
**Условие:** новый флаг выключен.
|
||||
- **PASS:** деплой ведёт себя байт-в-байт как до ORCH-112 (голый `git pull origin main`); включение
|
||||
флага активирует устойчивое поведение; область скоупится на self-hosting (прочие репо не затронуты).
|
||||
- **FAIL:** выключенный флаг меняет поведение, либо нет kill-switch, либо затронуты прочие репо.
|
||||
|
||||
---
|
||||
|
||||
## AC-7 — Сходимость после cancel/failed
|
||||
|
||||
**Условие:** задача отменена (STOP/ORCH-090) или job завершился `failed`, оставив остатки в
|
||||
рабочем дереве deploy-базы.
|
||||
- **PASS:** shared-база сходится к чистому `origin/main` (через автоочистку в деплое и/или дворник),
|
||||
и последующий self-deploy проходит без ручного вмешательства; покрыто интеграционным тестом.
|
||||
- **FAIL:** остатки сохраняются и блокируют последующий деплой/git-операцию.
|
||||
|
||||
---
|
||||
|
||||
## AC-8 — Наблюдаемость
|
||||
|
||||
**Условие:** обнаружена грязная deploy-база.
|
||||
- **PASS:** факт обнаружения и автоочистки (или отказ) — в логах (структурно) и в Telegram-алерте
|
||||
(кликабельный номер); алерт best-effort, never-raise (его сбой не валит деплой).
|
||||
- **FAIL:** тихая очистка без следа в логах/уведомлениях, либо сбой алерта роняет деплой.
|
||||
|
||||
---
|
||||
|
||||
## AC-9 — Инвариант конвейера и БД не тронуты
|
||||
|
||||
**Условие:** диф задачи.
|
||||
- **PASS:** `STAGE_TRANSITIONS` / реестр `QG_CHECKS` / семантика и имена `check_*` / machine-verdict
|
||||
ключи / схема БД / exit-code-контракт хука — байт-в-байт; структурные тесты конвейера зелёные.
|
||||
- **FAIL:** любой из перечисленных контрактов изменён.
|
||||
|
||||
---
|
||||
|
||||
## AC-10 — Документация (golden source)
|
||||
|
||||
**Условие:** изменён функционал deploy-базы/гигиены.
|
||||
- **PASS:** обновлены `docs/operations/INFRA.md` (инвариант deploy-база ≠ workspace) и
|
||||
`docs/architecture/README.md`; `CHANGELOG.md`/`CLAUDE.md` отражают изменение; ADR заведён на
|
||||
стадии `architecture` (после эскалации `escalate: full-cycle`).
|
||||
- **FAIL:** функционал изменён, доки/ADR не обновлены (reviewer → finding ≥P1, CLAUDE.md §6).
|
||||
|
||||
---
|
||||
|
||||
## Сводная матрица AC ↔ FR/BR
|
||||
| AC | Покрывает |
|
||||
|----|-----------|
|
||||
| AC-1 | BR-1 / FR-1 |
|
||||
| AC-2 | BR-1, BR-2 / FR-1, FR-2 |
|
||||
| AC-3 | NFR-2 / FR-1 (ограничение) |
|
||||
| AC-4 | BR-5 / FR-1 |
|
||||
| AC-5 | NFR-1 / FR-1, FR-2 |
|
||||
| AC-6 | NFR-3, NFR-6 / FR-5 |
|
||||
| AC-7 | BR-2 / FR-2 |
|
||||
| AC-8 | BR-4 / FR-4 |
|
||||
| AC-9 | NFR-5 / FR-1…FR-5 |
|
||||
| AC-10 | BR-3 / FR-3 |
|
||||
83
docs/work-items/ORCH-112/04-test-plan.yaml
Normal file
83
docs/work-items/ORCH-112/04-test-plan.yaml
Normal file
@@ -0,0 +1,83 @@
|
||||
work_item: ORCH-112
|
||||
stage: analysis
|
||||
author_agent: analyst
|
||||
status: ready-for-review
|
||||
created_at: 2026-06-15
|
||||
model_used: claude-opus-4-8
|
||||
title: "Гигиена shared deploy-базы: устойчивость self-deploy git pull к грязному дереву"
|
||||
framework: pytest
|
||||
scope: >
|
||||
Покрывается: устойчивость self-deploy `git pull origin main` к грязной shared deploy-базе
|
||||
(модифицированные tracked + untracked файлы), сходимость базы к чистому origin/main после
|
||||
failed/cancelled задач, сохранность deploy-rollback-состояния, kill-switch/область, наблюдаемость,
|
||||
self-hosting safety. Вне покрытия: модель worktree per-task (ORCH-2, не трогается), запрет ручных
|
||||
операций оператора, изменения STAGE_TRANSITIONS/QG_CHECKS/схемы БД (их нет).
|
||||
notes: >
|
||||
TC-01 — ОБЯЗАТЕЛЬНЫЙ регресс-тест воспроизведения инцидента ORCH-111: КРАСНЫЙ до фикса, ЗЕЛЁНЫЙ
|
||||
после. Шелл-симуляции хука моделировать по образцу tests/test_deploy_hook_rollback_sim.py
|
||||
(временный git-репо во временной директории, без сети/прода/ssh). Полный регресс `pytest tests/ -q`
|
||||
обязан оставаться зелёным (NFR-5). Точные имена тест-модулей/функций уточнит разработчик; тип
|
||||
гигиены (resilient-pull / janitor / guard) выберет архитектор — тесты сформулированы так, чтобы
|
||||
проверять ТРЕБУЕМЫЙ ИНВАРИАНТ (база сходится к чистому origin/main, артефакты сохранены), а не
|
||||
конкретный механизм.
|
||||
|
||||
tests:
|
||||
- id: TC-01
|
||||
type: integration
|
||||
description: "РЕГРЕСС (обязательный, red→green): shared deploy-база с локальной модификацией tracked-файла src/config.py + untracked файлами — симуляция шага git pull хука приводит базу к чистому origin/main и НЕ падает с 'local changes would be overwritten by merge' (воспроизводит ORCH-111; красный до фикса)."
|
||||
module: tests/test_deploy_checkout_hygiene.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-02
|
||||
type: integration
|
||||
description: "Untracked WIP-файлы (install_lite.py / test_install_lite.py / lite-install.example.yaml) в shared-базе не блокируют операцию и база сходится к чистому origin/main."
|
||||
module: tests/test_deploy_checkout_hygiene.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-03
|
||||
type: integration
|
||||
description: "Сохранность (NFR-2): после гигиены файлы $REPO/.deploy-prev-image-* , deploy-hook.log, sibling .deploy-state-* / .merge-lease-*.json и .git/worktrees/* НЕ удалены; rollback по .deploy-prev-image-* остаётся работоспособным."
|
||||
module: tests/test_deploy_checkout_hygiene.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-04
|
||||
type: integration
|
||||
description: "Happy-path без регресса: на ЧИСТОЙ shared-базе шаг pull — обычный fast-forward; наблюдаемое поведение и exit-коды (0/1/2, ORCH-036) байт-в-байт прежние."
|
||||
module: tests/test_deploy_checkout_hygiene.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-05
|
||||
type: unit
|
||||
description: "Self-hosting safety: путь гигиены никогда не оперирует веткой main на remote, не делает force-push, не рестартит прод и не сносит worktree/ветки других задач; операции ограничены настроенным путём deploy-базы (статический/поведенческий ассерт)."
|
||||
module: tests/test_deploy_checkout_hygiene.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-06
|
||||
type: unit
|
||||
description: "Kill-switch off → деплой/pull байт-в-байт прежний (голый git pull origin main); on → активна устойчивая гигиена. Область applies(repo): self-hosting orchestrator real, прочие репо — no-op."
|
||||
module: tests/test_deploy_checkout_hygiene.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-07
|
||||
type: integration
|
||||
description: "Сходимость после cancel/failed: cancel_task (ORCH-090) / failed-исход не оставляет рабочих остатков в shared-базе, блокирующих будущий деплой; последующий self-deploy проходит без ручного вмешательства."
|
||||
module: tests/test_deploy_checkout_hygiene.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-08
|
||||
type: unit
|
||||
description: "Наблюдаемость: обнаружение грязной базы и факт автоочистки (или отказ) попадают в лог; Telegram-алерт best-effort/never-raise (его сбой не валит деплой), номер задачи кликабельный."
|
||||
module: tests/test_deploy_checkout_hygiene.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-09
|
||||
type: unit
|
||||
description: "Инвариант конвейера: STAGE_TRANSITIONS / реестр QG_CHECKS / имена и семантика check_* / machine-verdict ключи / exit-code-контракт хука — не изменены (структурный анти-регресс)."
|
||||
module: tests/test_deploy_checkout_hygiene.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-10
|
||||
type: unit
|
||||
description: "Документация-инвариант: docs/operations/INFRA.md и docs/architecture/README.md содержат правило «shared main checkout — deploy/worktree-management база, не workspace» (структурная сверка)."
|
||||
module: tests/test_deploy_checkout_hygiene.py
|
||||
expected: PASS
|
||||
@@ -0,0 +1,244 @@
|
||||
---
|
||||
work_item: ORCH-112
|
||||
stage: architecture
|
||||
author_agent: architect
|
||||
status: proposed
|
||||
created_at: 2026-06-15
|
||||
model_used: claude-opus-4-8
|
||||
---
|
||||
|
||||
# ADR-001: Гигиена shared deploy-базы — устойчивый self-deploy `git pull` через resilient-pull в хуке
|
||||
|
||||
Work Item: **ORCH-112** — failed/cancelled task artifacts must be cleaned from shared checkout
|
||||
Стадия: **architecture**
|
||||
Сквозная регистрация: **`docs/architecture/adr/adr-0044-deploy-base-checkout-hygiene.md`** (решение
|
||||
кросс-каттинговое: новый leaf-компонент на глобальном пути прод-деплоя + изменение прод-deploy-хука).
|
||||
|
||||
## Статус
|
||||
Proposed
|
||||
|
||||
## Контекст
|
||||
|
||||
Self-deploy задачи **ORCH-111** упал на шаге `git pull origin main` хост-хука с
|
||||
`error: Your local changes to the following files would be overwritten by merge: src/config.py`.
|
||||
Деплой прерван → потребовал ручного вмешательства (на self-hosting это групповой риск — встаёт
|
||||
деплой всех проектов). Грязь оставлена ранее **неуспешной/отменённой/перезапущенной** задачей
|
||||
ORCH-104 в общем (shared) checkout.
|
||||
|
||||
Факты, сверенные с кодом:
|
||||
|
||||
1. **Точка отказа — голый pull.** `scripts/orchestrator-deploy-hook.sh:223-226` делает `cd "$REPO"`
|
||||
(= `settings.deploy_host_repo_path` = main clone, строка 73) и **голый** `git pull origin main`
|
||||
**без гигиены рабочего дерева**. Любая локальная правка tracked-файла блокирует merge. Во всём
|
||||
`src/`+`scripts/` нет ни одного `git reset --hard` / `git clean` / `git stash` для приведения базы
|
||||
к чистому состоянию.
|
||||
|
||||
2. **Инвариант «main clone ≠ workspace» соблюдён, но не задокументирован и не энфорснут.** Агенты
|
||||
работают в worktree'ах `/repos/_wt/<repo>/<branch>` (`git_worktree.ensure_worktree`); `docker build`
|
||||
берёт контекст **worktree** (`image_freshness._host_worktree_path`); fallback'и гейтов на main clone —
|
||||
**только чтение** (`git show origin/main:...`). Значит грязь — почти наверняка ручной/брошенный WIP.
|
||||
Но **нет механизма**, детектирующего/чистящего грязную базу, и **нет задокументированного
|
||||
инварианта**.
|
||||
|
||||
3. **`cancel_task` чистит worktree + remote-ветку, но не shared checkout.** `stage_engine.cancel_task`
|
||||
(строки 2333-2342): `remove_worktree(repo, branch)` + `gitea.delete_remote_branch(repo, branch)` —
|
||||
нулевое покрытие случая «грязная deploy-база».
|
||||
|
||||
4. **NFR-2 (сохранность) — жёсткое ограничение.** Сверено `.gitignore` + `src/config.py`:
|
||||
- `.env`, `data/`, `*.db`, `.venv/`, `__pycache__/`, `build/`, `.env.staging`, `.env.watchdog`,
|
||||
`deploy/bundled/repos/` — **gitignored** → переживают `git clean -fd` (без `-x`) автоматически.
|
||||
**Прод-секреты `.env` и БД `data/*.db` пропадут только при ошибочном `-x`.**
|
||||
- `.deploy-prev-image-prod` (`deploy_prod_prev_image_file`) / `.deploy-prev-image-staging`,
|
||||
а также `deploy-hook.log` (fallback-локация лога, хук строки 60-65) — **untracked, НЕ gitignored**
|
||||
→ голый `git clean -fd` их **удалит** → сломает rollback (`do_rollback`, хук строки 107-139).
|
||||
- Sibling-состояния `<repos_dir>/.deploy-state-<repo>/...` (`self_deploy._state_dir`) и
|
||||
`<repos_dir>/.merge-lease-<repo>.json` (`merge_gate`, строка 315) лежат под **родителем** `$REPO`
|
||||
(`settings.repos_dir`, не `$REPO`) → `git clean` в `$REPO` их физически не достаёт.
|
||||
- `.git/worktrees/*` (админ-записи worktree) — внутри `.git/`, `git clean` его **никогда** не трогает.
|
||||
|
||||
«Как есть» не годится: deploy-база трактуется как «всегда чистая», что не гарантировано; устойчивость
|
||||
должна быть на стороне системы — база обязана **самовосстанавливаться** в чистый `origin/main` перед pull.
|
||||
|
||||
## Решение
|
||||
|
||||
### Сводка
|
||||
|
||||
Сделать self-deploy `git pull` **устойчивым к грязной shared deploy-базе** через **resilient-pull,
|
||||
встроенный в хук** (`--deploy`): перед `git pull origin main` добавляется детерминированный
|
||||
hygiene-блок, который при обнаружении грязного дерева приводит базу к чистому актуальному `origin/main`
|
||||
(`git fetch` + `git reset --hard origin/main` + **скоупленный** `git clean -fd`), **строго сохраняя**
|
||||
rollback/лог-артефакты (NFR-2). Блок гейтится env-переменной `CHECKOUT_HYGIENE`, которую инжектит
|
||||
`self_deploy.build_deploy_command` **только** когда новый leaf `src/checkout_hygiene.py`-`applies(repo)`
|
||||
истинен (kill-switch + self-hosting-скоуп). Сходимость базы после failed/cancelled (FR-2) достигается
|
||||
этим же deploy-time self-heal — **без** расширения `cancel_task` и **без** фонового «дворника».
|
||||
Наблюдаемость — sentinel-файл от хука, который читает Phase-C finalizer и шлёт Telegram-алерт.
|
||||
|
||||
**Инвариант (NFR-5):** `STAGE_TRANSITIONS` / реестр `QG_CHECKS` / семантика и имена `check_*` /
|
||||
machine-verdict ключи (`deploy_status:`/`staging_status:`/`security_status:`) / схема БД /
|
||||
exit-code-контракт хука (0/1/2, ORCH-036) — **байт-в-байт не тронуты**. Это устойчивость deploy-пути
|
||||
и политика гигиены, **не** Quality Gate и **не** стадия.
|
||||
|
||||
### D1 — Механизм: resilient-pull в хуке (а не janitor / не container-side clean) — FR-1, AC-1/AC-2
|
||||
|
||||
Падающий `git pull` исполняется **на хосте** внутри detached-хука (`self_deploy.build_deploy_command`
|
||||
→ ssh + setsid). Поэтому гигиена обязана выполняться **в самом хуке, на хосте, непосредственно перед
|
||||
pull** — это исключает TOCTOU-гонку, которая возникла бы при чистке shared-mount из контейнера
|
||||
параллельно бегущему detached-хуку.
|
||||
|
||||
Новый блок «2a. Resilient pull» в `scripts/orchestrator-deploy-hook.sh` **между** шагом «1. Capture
|
||||
PREV_IMG» и шагом «2. Pull latest code», под `if [[ "${CHECKOUT_HYGIENE:-0}" == "1" ]]`:
|
||||
|
||||
```bash
|
||||
# 2a. ORCH-112: resilient pull — converge the shared deploy-base to a clean
|
||||
# origin/main BEFORE the pull, so a dirty working tree (manual/abandoned WIP)
|
||||
# never blocks the deploy. Gated by CHECKOUT_HYGIENE (Python kill-switch +
|
||||
# self-hosting scope). NEVER `-x` (would delete .env/data/*.db); EXCLUDES the
|
||||
# rollback/log artefacts (NFR-2). Best-effort: any failure is logged and the
|
||||
# bare `git pull` below still runs (never-break).
|
||||
if [[ "${CHECKOUT_HYGIENE:-0}" == "1" ]]; then
|
||||
dirty="$(git status --porcelain 2>/dev/null || true)"
|
||||
if [[ -n "$dirty" ]]; then
|
||||
log "HYGIENE: dirty deploy-base detected, converging to origin/main:"
|
||||
log "$dirty"
|
||||
git fetch origin main >> "$LOG" 2>&1 || log "HYGIENE: fetch failed (continuing)"
|
||||
git reset --hard origin/main >> "$LOG" 2>&1 || log "HYGIENE: reset failed (continuing)"
|
||||
git clean -fd \
|
||||
-e '.deploy-prev-image-*' \
|
||||
-e 'deploy-hook.log' \
|
||||
>> "$LOG" 2>&1 || log "HYGIENE: clean failed (continuing)"
|
||||
if [[ -n "${HYGIENE_REPORT:-}" ]]; then
|
||||
{ printf 'dirty=1\n'; printf '%s\n' "$dirty"; } > "$HYGIENE_REPORT" 2>/dev/null || true
|
||||
fi
|
||||
else
|
||||
log "HYGIENE: deploy-base already clean (no-op)"
|
||||
fi
|
||||
fi
|
||||
```
|
||||
|
||||
- `git status --porcelain` детектирует грязь; **чистая база → блок no-op** (порожний вывод) → дальше
|
||||
обычный `git pull` (AC-4, happy-path байт-в-байт).
|
||||
- `git fetch origin main` обновляет `origin/main`-ref, чтобы `reset --hard origin/main` сходился к
|
||||
**актуальному** источнику истины; tracked-правки (`src/config.py`) дискардятся (AC-1).
|
||||
- `git clean -fd` снимает untracked-остатки (AC-2). **`-x` запрещён** (см. D2). Последующий
|
||||
`git pull origin main` (строка 226, **не изменяется**) становится no-op fast-forward.
|
||||
- Шаг 1 хука пишет `.deploy-prev-image-prod` **до** hygiene → exclude `-e '.deploy-prev-image-*'`
|
||||
сохраняет rollback-снимок **этого** деплоя (AC-3).
|
||||
|
||||
### D2 — Сохранность deploy-состояния: NFR-2 как жёсткий контракт — AC-3
|
||||
|
||||
**INV-HYGIENE-1 (никогда `-x`).** Hygiene-`git clean` исполняется **только** как `git clean -fd`
|
||||
(без `-x`). `-x` удалил бы gitignored `.env` (прод-секреты), `data/*.db` (БД прода), `build/` — что
|
||||
катастрофично. Анти-регресс: статический тест ассертит, что hygiene-блок хука **не содержит** токена
|
||||
`-x` (TC-05/TC-09).
|
||||
|
||||
**INV-HYGIENE-2 (явные excludes).** `.deploy-prev-image-*` и `deploy-hook.log` untracked-но-НЕ-ignored
|
||||
→ обязательны `-e '.deploy-prev-image-*'` и `-e 'deploy-hook.log'`. TC-03 доказывает их неудаление и
|
||||
работоспособность rollback после гигиены.
|
||||
|
||||
**INV-HYGIENE-3 (скоуп = `$REPO`).** Hygiene оперирует **только** рабочим деревом `$REPO`. Sibling
|
||||
`.deploy-state-*` / `.merge-lease-*.json` (под `<repos_dir>`, родитель `$REPO`) и `.git/worktrees/*`
|
||||
(внутри `.git/`) **вне** области `git clean` в `$REPO` — подтверждено топологией; гигиена их не достаёт.
|
||||
|
||||
### D3 — Гейтинг: leaf `src/checkout_hygiene.py` + инжекция env в `build_deploy_command` — FR-5, AC-6
|
||||
|
||||
Новый чистый never-raise leaf `src/checkout_hygiene.py` (по образцу `serial_gate`/`cancel`/`self_deploy`;
|
||||
импортирует только `config`, лениво — `qg.checks.is_self_hosting_repo`):
|
||||
|
||||
- `applies(repo) -> bool` — `checkout_hygiene_enabled=False` → `False` (kill-switch, голый pull 1:1 до
|
||||
ORCH-112); `checkout_hygiene_repos` (CSV) непуст → только перечисленные репо; **пусто →
|
||||
self-hosting only** (`is_self_hosting_repo`, как `self_deploy_repos`). Локальный, без сети, **первым**.
|
||||
- `hook_env(repo, work_item_id) -> str` — возвращает префикс `CHECKOUT_HYGIENE=1 HYGIENE_REPORT=<path>`
|
||||
(через `shlex.quote`) **только** при `applies==True`; иначе `""`. `HYGIENE_REPORT` =
|
||||
`os.path.join(self_deploy.host_state_dir(repo, work_item_id), "hygiene")` — в том же deploy-state
|
||||
каталоге, что и `result` (shared mount, читается контейнером).
|
||||
- `read_report(repo, work_item_id) -> dict | None` — читает sentinel `hygiene` через
|
||||
`self_deploy.container_state_dir`; never-raise.
|
||||
- `alert_dirty(...)` — best-effort `send_telegram` (кликабельный номер), never-raise (D5).
|
||||
- `snapshot() -> dict` — read-only блок для `GET /queue` (флаг/скоуп; опционально).
|
||||
|
||||
`self_deploy.build_deploy_command` (строки 253-261) добавляет `checkout_hygiene.hook_env(repo, wi)` в
|
||||
`env_assignments` (одна строка-присваивание в префиксе detached-команды; точно как ORCH-058
|
||||
`EXPECTED_REVISION`). Когда `applies==False` → префикс пуст → хук видит `CHECKOUT_HYGIENE` неустановленным
|
||||
→ блок 2a — no-op → **голый `git pull` (1:1 до ORCH-112)**.
|
||||
|
||||
Флаги в `src/config.py` (дефолт = боевое):
|
||||
- `checkout_hygiene_enabled: bool = True` (env `ORCH_CHECKOUT_HYGIENE_ENABLED`);
|
||||
- `checkout_hygiene_repos: str = ""` (env `ORCH_CHECKOUT_HYGIENE_REPOS`; пусто → self-hosting only).
|
||||
|
||||
### D4 — Сходимость после failed/cancelled: deploy-time self-heal достаточен; `cancel_task` НЕ расширяется — FR-2, AC-7
|
||||
|
||||
Нормальный конвейер **не пишет** в main clone (факт 2 контекста) → грязь — ручной/брошенный WIP.
|
||||
Resilient-pull (D1) приводит базу к чистому `origin/main` **на следующем же self-deploy** → база
|
||||
**сходится** независимо от источника остатков → FR-2/AC-7 удовлетворены deploy-time self-heal.
|
||||
|
||||
`stage_engine.cancel_task` (ORCH-090) **намеренно не расширяется** на shared-базу:
|
||||
- избегаем касания критично-оконного каскада ORCH-090 (`cancel.in_critical_window`, adr-0026);
|
||||
- избегаем container-side мутации deploy-базы (гонка с возможным параллельным деплоем);
|
||||
- расширение было бы избыточным — self-heal в D1 уже гарантирует сходимость.
|
||||
|
||||
**Отвергнут активный «дворник» (janitor)** (фоновый актор, приводящий базу к чистоте по таймеру/событию):
|
||||
новый background-поток + дополнительная гонка с detached-деплоем и держателем merge-lease, без выигрыша
|
||||
сверх deploy-time self-heal.
|
||||
|
||||
### D5 — Наблюдаемость: sentinel хука → Telegram-алерт finalizer'а — FR-4, AC-8
|
||||
|
||||
Detached-хук (bash) не шлёт Telegram сам. Он пишет sentinel `hygiene` (`HYGIENE_REPORT`) в deploy-state
|
||||
каталог. Phase-C finalizer (`stage_engine`, уже читает `result` через `self_deploy.read_result`) после
|
||||
маппинга exit-code вызывает `checkout_hygiene.read_report` + `alert_dirty` (best-effort):
|
||||
структурный лог + Telegram (`send_telegram`, кликабельный `ORCH-112` через `plane_issue_link`,
|
||||
`disable_web_page_preview`). Сбой алерта **не валит** деплой (never-raise, AC-8). Опционально — блок
|
||||
`checkout_hygiene` в `GET /queue`.
|
||||
|
||||
### D6 — Инвариант deploy-базы: документирование + de-facto энфорс — FR-3, AC-10
|
||||
|
||||
Инвариант «`<host_repos_dir>/<repo>` (main checkout) — deploy/worktree-management база, **НЕ**
|
||||
редактируемый workspace; рабочие изменения туда не пишутся конвейером/агентами» документируется в
|
||||
`docs/operations/INFRA.md` (топология + self-hosting) и `docs/architecture/README.md` (раздел ORCH-36).
|
||||
**De-facto энфорс** — сам resilient-pull (D1): любая локальная правка дискардится при следующем деплое.
|
||||
**Отвергнут** тяжёлый рантайм-guard на горячих путях (FR-3 «где осуществимо, без изменения горячих
|
||||
путей») — он не нужен: self-heal уже обеспечивает сходимость, а guard добавил бы риск/латентность.
|
||||
|
||||
### D7 — Скоуп пути: только `--deploy` pull, не `--build-staging`
|
||||
|
||||
Hygiene врезается **только** в режим `--deploy` (где есть падающий `git pull`). Режим `--build-staging`
|
||||
(хук строки 166-204) собирает образ из `BUILD_CONTEXT` = **worktree** и **не делает** `git pull` →
|
||||
hygiene там не нужна и не добавляется.
|
||||
|
||||
## Альтернативы
|
||||
|
||||
- **`git stash` вместо `reset --hard`** — отвергнуто: накапливает stash-записи в deploy-базе, не
|
||||
«сходится к чистому origin/main», прячет источник грязи; сложнее и менее детерминирован.
|
||||
- **Container-side Python clean перед ssh-dispatch** — отвергнуто (D1): TOCTOU-гонка с detached-хуком,
|
||||
дублирование пути deploy-базы.
|
||||
- **Активный фоновый janitor** — отвергнуто (D4): новый актор + гонки, нулевой выигрыш над self-heal.
|
||||
- **Расширить `cancel_task` на shared-базу** — отвергнуто (D4): касание критично-оконного каскада
|
||||
ORCH-090 + container-side мутация; избыточно.
|
||||
- **Тяжёлый рантайм-guard инварианта** — отвергнуто (D6): не нужен при self-heal, риск на горячем пути.
|
||||
- **`git clean -xfd`** — **категорически** отвергнуто (D2/INV-HYGIENE-1): удалит `.env`/`data/*.db`.
|
||||
|
||||
## Последствия
|
||||
|
||||
- **+** Self-deploy `git pull` устойчив к грязной shared-базе → нет ручного разруливания (BR-1, AC-1/2).
|
||||
- **+** База **сходится** к чистому `origin/main` после failed/cancelled/брошенных задач (BR-2, AC-7).
|
||||
- **+** Happy-path и kill-switch-off — байт-в-байт (BR-5, AC-4/AC-6); конвейер/БД/exit-коды не тронуты.
|
||||
- **+** Наблюдаемость: оператор видит факт автоочистки (BR-4, AC-8); инвариант задокументирован (AC-10).
|
||||
- **−** `reset --hard` **дискардит** локальные правки deploy-базы безвозвратно. Митигейшн: это **ровно**
|
||||
инвариант (база = `origin/main`, источник истины — remote; deploy лишь fast-forward'ит); скоуп —
|
||||
self-hosting; наблюдаемость показывает, что именно сброшено.
|
||||
- **−** Новый leaf + хук-блок + флаги = площадь сопровождения. Митигейшн: leaf минимальный, образец
|
||||
существующих leaf'ов; never-raise; полный набор анти-регресс-тестов (`04-test-plan.yaml`).
|
||||
- **Откат:** `ORCH_CHECKOUT_HYGIENE_ENABLED=false` → деплой байт-в-байт до ORCH-112 (голый
|
||||
`git pull origin main`); полный откат — revert leaf + хук-блок + флагов.
|
||||
|
||||
## Ссылки
|
||||
- BRD: `docs/work-items/ORCH-112/01-brd.md`
|
||||
- TRZ: `docs/work-items/ORCH-112/02-trz.md`
|
||||
- Acceptance: `docs/work-items/ORCH-112/03-acceptance-criteria.md`
|
||||
- Test-plan: `docs/work-items/ORCH-112/04-test-plan.yaml`
|
||||
- Infra-requirements: `docs/work-items/ORCH-112/07-infra-requirements.md`
|
||||
- Tech-risks: `docs/work-items/ORCH-112/10-tech-risks.md`
|
||||
- Сквозной ADR: `docs/architecture/adr/adr-0044-deploy-base-checkout-hygiene.md`
|
||||
- Сверено по коду: `scripts/orchestrator-deploy-hook.sh:73,107-139,223-226`, `src/self_deploy.py:119-135,
|
||||
220-278`, `src/merge_gate.py:315`, `src/config.py:285-291`, `.gitignore`, `src/stage_engine.py:2333-2342`
|
||||
- Трассировка (CLAUDE.md §9 / ORCH-078): ORCH-036 (self-deploy, adr-0007), ORCH-058 (image-freshness,
|
||||
adr-0008), ORCH-090 (cancel, adr-0026) — инварианты не нарушаются.
|
||||
59
docs/work-items/ORCH-112/07-infra-requirements.md
Normal file
59
docs/work-items/ORCH-112/07-infra-requirements.md
Normal file
@@ -0,0 +1,59 @@
|
||||
---
|
||||
work_item: ORCH-112
|
||||
stage: architecture
|
||||
author_agent: architect
|
||||
status: proposed
|
||||
created_at: 2026-06-15
|
||||
model_used: claude-opus-4-8
|
||||
---
|
||||
|
||||
# 07 — Инфра-требования: ORCH-112 — гигиена shared deploy-базы
|
||||
|
||||
Work Item: **ORCH-112** · Repo: **orchestrator** · Стадия: architecture
|
||||
|
||||
> When-applicable. Затрагивается **жизненный цикл shared deploy-базы** (`<host_repos_dir>/<repo>`),
|
||||
> а не топология контейнеров/портов/томов. Контейнеры/сеть/тома — `N/A`.
|
||||
|
||||
## I-1. Топология / окружения
|
||||
**Без изменений в составе.** Контейнеры (`orchestrator` 8500 / `orchestrator-staging` 8501), сеть
|
||||
(`network_mode: host`), порты, тома — прежние.
|
||||
|
||||
Затрагивается **deploy-база** `<host_repos_dir>/<repo>` (= `/home/slin/repos/orchestrator` ==
|
||||
`/repos/orchestrator` в контейнере через bind-mount == `settings.deploy_host_repo_path`). **Нормативно
|
||||
закрепляется инвариант:** deploy-база — **deploy/worktree-management база, НЕ редактируемый workspace**.
|
||||
Рабочие изменения туда не пишутся конвейером/агентами (агенты — worktree `/repos/_wt/<repo>/<branch>`,
|
||||
build — worktree-контекст, fallback'и гейтов — read-only `git show origin/main`). Документируется в
|
||||
`docs/operations/INFRA.md` (топология + self-hosting) и `docs/architecture/README.md` (раздел ORCH-36).
|
||||
|
||||
**Контракт сохранности рабочего дерева deploy-базы (NFR-2, жёсткий):** автоочистка hygiene
|
||||
(`git clean -fd`, **без** `-x`) **обязана сохранять**:
|
||||
| Артефакт | Расположение | Почему сохраняется |
|
||||
|----------|--------------|--------------------|
|
||||
| `.deploy-prev-image-prod` / `.deploy-prev-image-staging` | `$REPO/` (untracked, НЕ ignored) | rollback-снимок → `-e '.deploy-prev-image-*'` |
|
||||
| `deploy-hook.log` | `$REPO/` (fallback-лог) | аудит → `-e 'deploy-hook.log'` |
|
||||
| `.env`, `data/`, `*.db`, `build/` | `$REPO/` (gitignored) | прод-секреты/БД → переживают `git clean` **без** `-x` |
|
||||
| `.deploy-state-<repo>/*`, `.merge-lease-<repo>.json` | `<repos_dir>/` (sibling, родитель `$REPO`) | вне области `git clean` в `$REPO` |
|
||||
| `.git/worktrees/*` | `$REPO/.git/` | `git clean` никогда не трогает `.git/` |
|
||||
|
||||
## I-2. Переменные окружения / секреты
|
||||
Две новые env-переменные (`src/config.py`, дефолт = боевое; **обновить `.env.example`**):
|
||||
| Ключ | Env | Дефолт | Назначение |
|
||||
|------|-----|--------|-----------|
|
||||
| `checkout_hygiene_enabled` | `ORCH_CHECKOUT_HYGIENE_ENABLED` | `True` | kill-switch resilient-pull; `False` → голый `git pull` (1:1 до ORCH-112) |
|
||||
| `checkout_hygiene_repos` | `ORCH_CHECKOUT_HYGIENE_REPOS` | `""` | CSV-скоуп; пусто → self-hosting only (`orchestrator`) |
|
||||
|
||||
Внутренние env, инжектируемые в detached-хук `self_deploy.build_deploy_command` (не операторские):
|
||||
`CHECKOUT_HYGIENE=1`, `HYGIENE_REPORT=<host_state_dir>/hygiene`. Новых секретов нет.
|
||||
|
||||
## I-3. Деплой / рестарт
|
||||
**Рестарт прод-контейнера задачей ORCH-112 — НЕ требуется и ЗАПРЕЩЁН** (self-hosting инвариант,
|
||||
CLAUDE.md). Изменение активируется штатно: новый промпт/хук/код `cat`-ается/деплоится в обычном
|
||||
self-deploy-цикле через **staging-гейт (8501)** сначала, затем `Confirm Deploy` (ORCH-059). Хук-блок
|
||||
hygiene исполняется **только** в `--deploy` режиме (где есть `git pull`); `--build-staging` собирает из
|
||||
worktree и не пуллит → не затронут. Exit-code-контракт хука (0/1/2, ORCH-036) — байт-в-байт.
|
||||
|
||||
## I-4. CI/CD
|
||||
**Без изменений** `.gitea/workflows/`. Новый тест-модуль `tests/test_deploy_checkout_hygiene.py`
|
||||
(шелл-симуляция хука во временном git-репо, без сети/прода/ssh — образец
|
||||
`tests/test_deploy_hook_rollback_sim.py`) исполняется обычным `pytest tests/ -q`. Полный регресс обязан
|
||||
оставаться зелёным (NFR-5).
|
||||
42
docs/work-items/ORCH-112/10-tech-risks.md
Normal file
42
docs/work-items/ORCH-112/10-tech-risks.md
Normal file
@@ -0,0 +1,42 @@
|
||||
---
|
||||
work_item: ORCH-112
|
||||
stage: architecture
|
||||
author_agent: architect
|
||||
status: proposed
|
||||
created_at: 2026-06-15
|
||||
model_used: claude-opus-4-8
|
||||
---
|
||||
|
||||
# 10 — Технические риски: ORCH-112 — гигиена shared deploy-базы
|
||||
|
||||
Work Item: **ORCH-112** · Repo: **orchestrator** · Стадия: architecture
|
||||
|
||||
> Информационный (гейтом не парсится). Риски реализации resilient-pull и их митигейшн.
|
||||
|
||||
## Реестр рисков
|
||||
|
||||
| ID | Риск | Вер. | Влия. | Митигейшн |
|
||||
|----|------|------|-------|-----------|
|
||||
| TR-1 | **`git clean -x`** ошибочно добавлен → удалит gitignored `.env` (прод-секреты) / `data/*.db` (БД прода) / `build/` — катастрофа на прод-deploy-базе | Низ. | **Выс.** | INV-HYGIENE-1 (ADR D2): только `git clean -fd`, **никогда** `-x`. Статический анти-регресс-тест ассертит отсутствие `-x` в hygiene-блоке хука (TC-05/TC-09); явная рамка в ADR + INFRA |
|
||||
| TR-2 | Неверный/отсутствующий `-e` exclude → `git clean -fd` удалит `.deploy-prev-image-*` → сломан rollback (`do_rollback`) | Сред. | **Выс.** | INV-HYGIENE-2: обязательные `-e '.deploy-prev-image-*'` + `-e 'deploy-hook.log'`; TC-03 доказывает неудаление и работоспособность rollback; шаг 1 хука пишет prev-image **до** hygiene |
|
||||
| TR-3 | Сбой шага hygiene (fetch/reset/clean) маскирует/ухудшает исход деплоя | Низ. | Сред. | never-break (ADR D1): каждая git-операция `\|\| log "...continuing"`; деплой продолжается к голому `git pull`; sentinel-отчёт фиксирует факт; fail-safe, исход не хуже текущего |
|
||||
| TR-4 | `reset --hard origin/main` безвозвратно дискардит локальные правки deploy-базы, которые кто-то «нужными» считал | Низ. | Сред. | Это **ровно** инвариант: deploy-база = `origin/main` (источник истины — remote); deploy лишь fast-forward'ит. Наблюдаемость (D5) показывает сброшенное; скоуп — self-hosting; документировано (INFRA/README) |
|
||||
| TR-5 | Гонка: hygiene на хосте чистит mount, пока контейнер читает deploy-базу | Низ. | Низ. | Деплой сериализован (serial-gate ORCH-088, один деплой за раз); hygiene в detached-хуке непосредственно перед pull; нормальный конвейер deploy-базу не читает в этот момент (worktree-изоляция). TOCTOU между `status` и `reset` пренебрежимо (один процесс) |
|
||||
| TR-6 | Скоуп-leak: hygiene затронет прочие репо / синхронный деплой агентом | Низ. | Сред. | `applies(repo)` (локально, первым): пусто → self-hosting only; инжекция env только на self-deploy-пути (сам self-hosting-скоупленный, `self_deploy_applies`); TC-06 |
|
||||
| TR-7 | Стейл `origin/main` (fetch не выполнился) → `reset --hard` сходится к устаревшему коммиту | Низ. | Низ. | `git fetch origin main` перед reset; при сбое fetch — `\|\| log` + продолжение; последующий `git pull origin main` (строка 226, не тронута) до-тянет недостающее; deploy промоутит build-once `SOURCE_IMAGE` (артефакт не зависит от дерева main clone) |
|
||||
| TR-8 | Расширение объёма (касание `cancel_task`/`STAGE_TRANSITIONS`/QG) при реализации | Низ. | Сред. | ADR D4 явно запрещает расширение `cancel_task`; NFR-5 байт-в-байт; TC-09 структурно ассертит неизменность контрактов; трассировка ORCH-036/058/090 |
|
||||
|
||||
## Сводный вывод
|
||||
|
||||
Доминирующий класс — **деструктивная гигиена рядом с прод-rollback-состоянием** (TR-1/TR-2): низкая
|
||||
вероятность, высокое влияние, **полностью** снимается контрактом сохранности (INV-HYGIENE-1/2) + явными
|
||||
тестами (TC-03/TC-05/TC-09). Изменение аддитивно, под kill-switch (`checkout_hygiene_enabled`, дефолт
|
||||
`True`; off → байт-в-байт до ORCH-112), never-raise, self-hosting-скоупленное, **не трогает**
|
||||
`STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict/схему БД/exit-code-контракт хука.
|
||||
|
||||
**Эскалация:** вводится новый leaf-компонент (`src/checkout_hygiene.py`) на глобальном пути прод-деплоя
|
||||
+ правка прод-deploy-хука + сквозной ADR adr-0044 → рекомендуется лейбл **`arch:major-change`** для
|
||||
reviewer-внимания (изменение safety-critical, в прод-deploy-пути). Возврат в анализ **не** требуется:
|
||||
ТЗ удовлетворяется без нарушения принципов архитектуры (всё в Docker на одном сервере, без новых
|
||||
зависимостей/очередей/k8s, без рестарта прода вне staging-гейта). Остаточный риск для прод-конвейера —
|
||||
**низкий** при соблюдении INV-HYGIENE-1/2/3.
|
||||
123
docs/work-items/ORCH-112/12-review.md
Normal file
123
docs/work-items/ORCH-112/12-review.md
Normal file
@@ -0,0 +1,123 @@
|
||||
---
|
||||
verdict: APPROVED # APPROVED | REQUEST_CHANGES — строго одно из двух, UPPERCASE
|
||||
work_item: ORCH-112
|
||||
stage: review
|
||||
author_agent: reviewer
|
||||
status: approved
|
||||
created_at: 2026-06-15
|
||||
model_used: claude-opus-4-8
|
||||
type: review
|
||||
work_item_id: ORCH-112
|
||||
version: 1
|
||||
---
|
||||
|
||||
# Review ORCH-112 — deploy-base checkout-hygiene (resilient-pull)
|
||||
|
||||
## Summary
|
||||
|
||||
Багфикс инцидента **ORCH-111** (bug → escalate full-cycle): прод-self-deploy падал на голом
|
||||
`git pull origin main` хост-хука из-за грязного shared main checkout (остатки ORCH-104 от ORCH-104).
|
||||
Реализован **resilient-pull в хуке** (`--deploy`): перед pull при обнаружении грязи база сводится к
|
||||
чистому `origin/main` (`git fetch` + `git reset --hard origin/main` + скоупленный `git clean -fd`),
|
||||
под kill-switch, never-raise, скоуп self-hosting.
|
||||
|
||||
Проверены все 4 оси. Реализация **точно соответствует** ADR-001 (D1–D7) и сквозному adr-0044, все
|
||||
10 критериев приёмки (AC-1…AC-10) покрыты содержательными тестами, документация (golden source)
|
||||
обновлена в том же PR, инварианты конвейера/БД/exit-code-контракт хука — байт-в-байт не тронуты.
|
||||
|
||||
**Вердикт: APPROVED.** P0/P1/P2 findings отсутствуют.
|
||||
|
||||
### Что сверено (доказательная база)
|
||||
|
||||
**Ось 1 — соответствие ТЗ (02-trz) / критериям (03-acceptance-criteria):**
|
||||
- FR-1 (устойчивый pull) / AC-1 — ✅ хук-блок «2a. Resilient pull» + регресс **TC-01** (зелёный после
|
||||
фикса) и **TC-01b** (тот же грязный base без гигиены → `would be overwritten by merge`, репро инцидента).
|
||||
- FR-1 / AC-2 (untracked WIP) — ✅ **TC-02** (остатки сняты, не протекают в деплой).
|
||||
- NFR-2 / AC-3 (сохранность) — ✅ **TC-03** (`.deploy-prev-image-*`, `deploy-hook.log`, gitignored
|
||||
`.env`/`data/*.db`, sibling `.deploy-state-*`/`.merge-lease-*.json`, `.git/worktrees/*` — на месте) +
|
||||
**TC-05** статический контракт (`git clean -fd`, **никогда `-x`**, явные excludes).
|
||||
- BR-5 / AC-4 (happy-path) — ✅ **TC-04** (чистая база → no-op + fast-forward, exit-коды байт-в-байт).
|
||||
- NFR-1 / AC-5 (self-hosting safety) — ✅ скоуп `$REPO`, `reset --hard origin/main` (не локальная
|
||||
догадка), нет push/force-push (TC-05 ассерт).
|
||||
- FR-5 / AC-6 (kill-switch + обратимость) — ✅ **TC-06** (off → инертно; пустой CSV → self-hosting only;
|
||||
enduro не затронут).
|
||||
- FR-2 / AC-7 (сходимость после cancel/failed) — ✅ **TC-07** (deploy-time self-heal; `cancel_task`
|
||||
корректно НЕ расширён — D4).
|
||||
- FR-4 / AC-8 (наблюдаемость) — ✅ **TC-08** (`read_report`/`alert_dirty` never-raise) + врезка в
|
||||
`run_deploy_finalizer` (sentinel → Telegram, best-effort).
|
||||
- NFR-5 / AC-9 (инвариант конвейера/БД) — ✅ **TC-09** + проверка дифа: `STAGE_TRANSITIONS`/`QG_CHECKS`/
|
||||
`check_*`/machine-verdict/схема БД/exit-code-контракт хука (0/1/2) не тронуты.
|
||||
- BR-3 / AC-10 (документация) — ✅ см. ось 4.
|
||||
|
||||
**Ось 2 — соответствие ADR:**
|
||||
- ADR-001 D1–D7 реализованы дословно: resilient-pull в хуке (не janitor/не container-side, D1),
|
||||
NEVER `-x` + excludes (D2), leaf `checkout_hygiene.py` + инжекция env в `build_deploy_command` (D3),
|
||||
`cancel_task` не расширяется / janitor не вводится (D4), sentinel → finalizer-alert (D5), docs (D6),
|
||||
только `--deploy` не `--build-staging` (D7, подтверждено размещением блока между шагами 1 и 2 пути
|
||||
`--deploy`).
|
||||
- Трассировка (ORCH-078): правка `build_deploy_command` (маркеры ORCH-101/ORCH-058) — чисто аддитивна
|
||||
(одно env-присваивание после `EXPECTED_REVISION`), инвариант image-freshness не сломан; ORCH-036
|
||||
exit-code-контракт и ORCH-090 cancel-каскад не нарушены; INV-4 (никогда push/force-push `main`)
|
||||
соблюдён.
|
||||
|
||||
**Ось 3 — качество кода:**
|
||||
- Leaf чистый, never-raise, ленивые импорты (`self_deploy`/`qg.checks`/`notifications`) — leaf-инвариант
|
||||
доказан **TC-05** (`leaf_is_a_pure_leaf`). Docstrings на всех публичных функциях. `shlex.quote` на
|
||||
инжектируемом пути. Env-проводка консистентна с существующим паттерном `result`-sentinel
|
||||
(`initiate_deploy` пред-создаёт `container_state_dir` → запись `hygiene` гарантированно проходит).
|
||||
- **Багфикс-трек регресс-тест (ORCH-019 / BR-4):** присутствует — **TC-01** (фиксатор дефекта,
|
||||
зелёный после фикса) в паре с **TC-01b** (репродукция инцидента: голый pull аборт без гигиены).
|
||||
- Все ссылки на API существуют (`notifications.link_for`/`send_telegram`, `self_deploy.host_state_dir`/
|
||||
`container_state_dir`, `qg.checks.is_self_hosting_repo`). `repo`/`work_item_id`/`task_id` в скоупе
|
||||
финализатора.
|
||||
- Тесты содержательные: 17 TC (шелл-симуляция реального хука в герметичном git-репо без сети/прода/ssh
|
||||
+ unit). Прогон: **17/17 зелёные**; смежные deploy/config/stage_engine/frontmatter — **200/200 зелёные**;
|
||||
docs/hardcode/canon — **101/101 зелёные**.
|
||||
|
||||
**Ось 4 — документация (golden source):**
|
||||
- `src/` изменён → документация обновлена **в том же PR**: `docs/operations/INFRA.md` (инвариант
|
||||
deploy-база ≠ workspace), `docs/architecture/README.md` (раздел ORCH-112 design), `CHANGELOG.md`,
|
||||
`CLAUDE.md` (паспортный блок), `.env.example` (новые ключи), ADR-001 + сквозной
|
||||
`docs/architecture/adr/adr-0044`. Консистентны между собой и с кодом.
|
||||
- Обзорные доки (ORCH-079): открытые пункты `README.md` «Известные ограничения» (Telegram 48h /
|
||||
intra-repo deps / batch-автоном) этим PR **не закрываются** → обновление не требуется. ✅
|
||||
- Витрина системы (ORCH-011): фикс — внутренняя устойчивость deploy-пути, **не** новая
|
||||
стадия/гейт/агент/интеграция/способность → витрина `docs/overview/` не затронута;
|
||||
`tests/test_system_docs.py` зелёный. ✅
|
||||
|
||||
## Findings
|
||||
|
||||
### P0 — Blocker
|
||||
- Нет.
|
||||
|
||||
### P1 — Must fix
|
||||
- Нет.
|
||||
|
||||
### P2 — Should fix
|
||||
- Нет.
|
||||
|
||||
### P3 — Nice-to-have (не блокирует, на усмотрение)
|
||||
- `tests/test_deploy_checkout_hygiene.py::test_tc05_hook_clean_is_never_destructive` ассертит
|
||||
`assert "-x" not in code` по **всем** исполняемым строкам хука. Текущий хук токена `-x` не содержит
|
||||
(тест зелёный), но будущая легитимная конструкция (`set -x`, `[ -x file ]`, `chmod +x`) ложно уронит
|
||||
ассерт. Можно сузить проверку до строки(ок) `git clean` — но это страховка критичного инварианта
|
||||
INV-HYGIENE-1, поэтому строгость намеренна и допустима. Не блокирует.
|
||||
|
||||
## Документация
|
||||
|
||||
**Статус: обновлена полностью, в том же PR (golden source соблюдён).**
|
||||
|
||||
| Документ | Статус |
|
||||
|----------|--------|
|
||||
| `docs/work-items/ORCH-112/06-adr/ADR-001-deploy-base-checkout-hygiene.md` | ✅ заведён (architecture, после escalate full-cycle) |
|
||||
| `docs/architecture/adr/adr-0044-deploy-base-checkout-hygiene.md` | ✅ сквозной ADR заведён |
|
||||
| `docs/operations/INFRA.md` | ✅ инвариант deploy-база ≠ workspace + страховка resilient-pull |
|
||||
| `docs/architecture/README.md` | ✅ раздел ORCH-112 (design) |
|
||||
| `CHANGELOG.md` | ✅ запись [Unreleased] |
|
||||
| `CLAUDE.md` | ✅ паспортный блок |
|
||||
| `.env.example` | ✅ `ORCH_CHECKOUT_HYGIENE_ENABLED` / `_REPOS` |
|
||||
| `docs/overview/` (витрина, ORCH-011) | ➖ не требуется (внутренний deploy-fix, не новая способность) |
|
||||
| `README.md` «Известные ограничения» (ORCH-079) | ➖ не требуется (открытые пункты не закрываются) |
|
||||
|
||||
Необновлённой документации при изменённом `src/` **нет** → ось 4 пройдена, P0 по документации
|
||||
отсутствует.
|
||||
71
docs/work-items/ORCH-112/13-test-report.md
Normal file
71
docs/work-items/ORCH-112/13-test-report.md
Normal file
@@ -0,0 +1,71 @@
|
||||
---
|
||||
result: PASS # PASS | FAIL — машинный вердикт, UPPERCASE
|
||||
work_item: ORCH-112
|
||||
stage: testing
|
||||
author_agent: tester
|
||||
status: pass
|
||||
created_at: 2026-06-15
|
||||
model_used: claude-opus-4-8
|
||||
type: test-report
|
||||
work_item_id: ORCH-112
|
||||
---
|
||||
|
||||
# Test Report — ORCH-112
|
||||
|
||||
Гигиена shared deploy-базы: устойчивость self-deploy `git pull` к грязному дереву
|
||||
(багфикс инцидента ORCH-111). Review-вердикт: **APPROVED** (`12-review.md`).
|
||||
|
||||
## Окружение
|
||||
- Python: 3.12.13
|
||||
- pytest: 8.3.3 (plugins: cov-5.0.0, anyio-4.13.0, asyncio-0.23.8)
|
||||
- Worktree: `/repos/_wt/orchestrator/feature_ORCH-112-bug-failed-cancelled-task-arti/`
|
||||
- Ветка: `feature/ORCH-112-bug-failed-cancelled-task-arti`
|
||||
- Дата: 2026-06-15
|
||||
|
||||
## Smoke API (read-only)
|
||||
| Endpoint | Результат |
|
||||
|----------|-----------|
|
||||
| `GET /health` | PASS — `{"status":"ok","service":"orchestrator"}` |
|
||||
| `GET /status` | PASS — задача 102 ORCH-112 на стадии `testing`, ветка совпадает |
|
||||
| `GET /queue` | PASS — блок `serial_gate` присутствует (ORCH-088); `auto_labels` присутствует |
|
||||
|
||||
Блок `checkout_hygiene`/`serial_gate`/`auto_labels` — все на месте в полезной нагрузке `/queue`,
|
||||
регресса смока нет.
|
||||
|
||||
## Покрытие ТЗ (TC из 04-test-plan.yaml ↔ 03-acceptance-criteria)
|
||||
|
||||
| TC ID | Описание | AC | Тест | Результат |
|
||||
|-------|----------|----|------|-----------|
|
||||
| TC-01 | Регресс ORCH-111: грязный tracked `src/config.py` + untracked → база сходится к чистому `origin/main`, pull не падает (red→green) | AC-1 | `test_tc01_dirty_tracked_edit_converges_and_deploys` (+ `test_tc01b_bare_pull_aborts_without_hygiene_documents_incident`) | PASS |
|
||||
| TC-02 | Untracked WIP-файлы не блокируют и не протекают в деплой | AC-2 | `test_tc02_untracked_wip_does_not_block` | PASS |
|
||||
| TC-03 | Сохранность `.deploy-prev-image-*`/`deploy-hook.log`/sibling `.deploy-state-*`/`.merge-lease-*.json`/`.git/worktrees/*` (NFR-2) | AC-3 | `test_tc03_preserves_rollback_and_sibling_artifacts` | PASS |
|
||||
| TC-04 | Happy-path: чистая база → fast-forward, exit-коды байт-в-байт | AC-4 | `test_tc04_clean_base_fast_forwards_no_op_hygiene` | PASS |
|
||||
| TC-05 | Self-hosting safety: нет операций над `main`/force-push/рестарта прода; `git clean -fd` (никогда `-x`); leaf чист | AC-5 | `test_tc05_hook_clean_is_never_destructive`, `test_tc05_leaf_is_a_pure_leaf` | PASS |
|
||||
| TC-06 | Kill-switch off → инертно; пустой CSV → self-hosting only; скоуп репо | AC-6 | `test_tc06_kill_switch_off_is_inert`, `test_tc06_empty_csv_is_self_hosting_only`, `test_tc06_csv_scope_limits_repos` | PASS |
|
||||
| TC-07 | Сходимость после cancel/failed → следующий self-deploy чист | AC-7 | `test_tc07_convergence_then_next_deploy_is_clean` | PASS |
|
||||
| TC-08 | Наблюдаемость: `read_report`/`alert_dirty`, Telegram best-effort/never-raise | AC-8 | `test_tc08_read_report_none_when_absent`, `test_tc08_read_report_parses_dirty_sentinel`, `test_tc08_alert_dirty_never_raises_on_send_failure` | PASS |
|
||||
| TC-09 | Инвариант конвейера: `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict/exit-code-контракт хука не тронуты | AC-9 | `test_tc09_pipeline_contracts_untouched`, `test_tc09_hook_exit_code_contract_intact` | PASS |
|
||||
| TC-10 | Документация-инвариант: INFRA.md и architecture/README.md содержат правило «main checkout — deploy-база, не workspace» | AC-10 | `test_tc10_docs_state_deploy_base_invariant` | PASS |
|
||||
|
||||
Каждый TC из `04-test-plan.yaml` выполнен и сопоставлен с критерием приёмки `03-acceptance-criteria.md`.
|
||||
TC-01 (обязательный red→green регресс инцидента ORCH-111) — зелёный; парный TC-01b документирует
|
||||
аборт голого pull без гигиены.
|
||||
|
||||
## Вывод pytest
|
||||
|
||||
### Целевой модуль `tests/test_deploy_checkout_hygiene.py`
|
||||
```
|
||||
collected 17 items
|
||||
... 17 passed, 1 warning in 7.51s
|
||||
```
|
||||
|
||||
### Полный регресс `pytest tests/ -q`
|
||||
```
|
||||
2018 passed, 1 warning in 342.01s (0:05:42)
|
||||
```
|
||||
(единственный warning — Pydantic V2 deprecation в `src/config.py:8`, существующий, не связан с задачей)
|
||||
|
||||
## Итог
|
||||
PASS — все 10 TC (17 тест-функций) зелёные, полный регресс 2018/2018 зелёный, smoke API OK
|
||||
(`/health`, `/status`, `/queue` с блоками `serial_gate` и `auto_labels`). Задача готова к переходу
|
||||
на `deploy-staging`.
|
||||
12
docs/work-items/ORCH-112/14-deploy-log.md
Normal file
12
docs/work-items/ORCH-112/14-deploy-log.md
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
deploy_status: SUCCESS
|
||||
work_item: ORCH-112
|
||||
hook_exit_code: 0
|
||||
deployed_by: deploy-finalizer
|
||||
---
|
||||
|
||||
# Deploy log — ORCH-036 executable self-deploy
|
||||
|
||||
Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`.
|
||||
|
||||
Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM.
|
||||
@@ -220,6 +220,35 @@ else
|
||||
log "No previous image captured (first deploy or service not running?)"
|
||||
fi
|
||||
|
||||
# 2a. ORCH-112: resilient pull — converge the shared deploy-base to a clean, current
|
||||
# origin/main BEFORE the pull, so a dirty working tree (manual/abandoned WIP left
|
||||
# by a failed/cancelled task) never blocks the deploy (incident ORCH-111, dirt from
|
||||
# ORCH-104). Gated by CHECKOUT_HYGIENE (Python kill-switch + self-hosting scope,
|
||||
# injected by self_deploy.build_deploy_command). NEVER `-x` (would delete gitignored
|
||||
# .env / data/*.db / build/); EXCLUDES the untracked-but-not-ignored rollback/log
|
||||
# artefacts .deploy-prev-image-* and deploy-hook.log (NFR-2). Best-effort: every git
|
||||
# step is `|| log "...continuing"` and the bare `git pull` below still runs
|
||||
# (never-break). On a CLEAN base the whole block is a no-op -> the happy-path
|
||||
# behaviour and exit-codes (0/1/2, ORCH-036) are byte-for-byte unchanged.
|
||||
if [[ "${CHECKOUT_HYGIENE:-0}" == "1" ]]; then
|
||||
dirty="$(git status --porcelain 2>/dev/null || true)"
|
||||
if [[ -n "$dirty" ]]; then
|
||||
log "HYGIENE: dirty deploy-base detected, converging to origin/main:"
|
||||
log "$dirty"
|
||||
git fetch origin main >> "$LOG" 2>&1 || log "HYGIENE: fetch failed (continuing)"
|
||||
git reset --hard origin/main >> "$LOG" 2>&1 || log "HYGIENE: reset failed (continuing)"
|
||||
git clean -fd \
|
||||
-e '.deploy-prev-image-*' \
|
||||
-e 'deploy-hook.log' \
|
||||
>> "$LOG" 2>&1 || log "HYGIENE: clean failed (continuing)"
|
||||
if [[ -n "${HYGIENE_REPORT:-}" ]]; then
|
||||
{ printf 'dirty=1\n'; printf '%s\n' "$dirty"; } > "$HYGIENE_REPORT" 2>/dev/null || true
|
||||
fi
|
||||
else
|
||||
log "HYGIENE: deploy-base already clean (no-op)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 2. Pull latest code (keeps the host working tree current for future builds;
|
||||
# the DEPLOYED artefact is the retagged SOURCE_IMAGE below when build-once).
|
||||
log "git pull origin main"
|
||||
|
||||
214
src/checkout_hygiene.py
Normal file
214
src/checkout_hygiene.py
Normal file
@@ -0,0 +1,214 @@
|
||||
"""ORCH-112 (ADR-001 / adr-0044): deploy-base checkout-hygiene leaf — pure policy.
|
||||
|
||||
Leaf module mirroring ``src/serial_gate.py`` / ``src/cancel.py`` / ``src/self_deploy.py``:
|
||||
pure, unit-testable, never-raise functions over ``config`` + the deploy-state sentinels.
|
||||
Module-level imports are limited to ``config`` (and stdlib); ``self_deploy``,
|
||||
``qg.checks.is_self_hosting_repo`` and ``notifications`` are imported LAZILY so this
|
||||
stays a leaf and an import cycle can never form.
|
||||
|
||||
What it answers / does (the MECHANISM — git fetch/reset/clean — lives in the host
|
||||
deploy hook ``scripts/orchestrator-deploy-hook.sh`` block "2a. Resilient pull"; this
|
||||
leaf only decides conditionality, builds the env gate, reads the report and alerts):
|
||||
|
||||
* ``applies(repo)`` — is resilient-pull hygiene REAL here?
|
||||
* ``hook_env(repo, work_item_id)`` — the ``CHECKOUT_HYGIENE=1 HYGIENE_REPORT=…``
|
||||
env prefix injected into the detached
|
||||
deploy-hook command ("" when not applies).
|
||||
* ``read_report(repo, work_item_id)`` — read the ``hygiene`` sentinel the hook wrote.
|
||||
* ``alert_dirty(repo, work_item_id, report)``— best-effort Telegram + structured log.
|
||||
* ``snapshot()`` — read-only block for ``GET /queue``.
|
||||
|
||||
never-raise contract (self-hosting safety): every public function degrades
|
||||
conservatively. ``applies`` -> False on error (hygiene inert == kill-switch off, the
|
||||
safe default that keeps the bare ``git pull`` 1:1 as before ORCH-112). ``hook_env`` ->
|
||||
"" on error (no env -> the hook's ``${CHECKOUT_HYGIENE:-0}`` guard stays 0). The report
|
||||
reader / alert swallow every error so a deploy is NEVER crashed by an observability
|
||||
hiccup (D5 / AC-8).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
|
||||
from .config import settings
|
||||
|
||||
logger = logging.getLogger("orchestrator.checkout_hygiene")
|
||||
|
||||
# Sentinel filename the hook writes (HYGIENE_REPORT points at it) and read_report
|
||||
# reads back. Lives in the SAME deploy-state dir as self_deploy's ``result`` (shared
|
||||
# mount visible to both host and container).
|
||||
REPORT_NAME = "hygiene"
|
||||
|
||||
# Repo tokens in the CSV scope must match this (mirrors serial_gate._REPO_TOKEN). The
|
||||
# CSV is operator config, not user input, but the guard is mandatory; an invalid token
|
||||
# is dropped.
|
||||
_REPO_TOKEN = re.compile(r"^[A-Za-z0-9._-]+$")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Conditionality (mirrors self_deploy_applies / serial_gate_applies)
|
||||
# ---------------------------------------------------------------------------
|
||||
def _scope_repos() -> set[str]:
|
||||
"""Sanitised set of in-scope repo tokens from ``checkout_hygiene_repos`` (CSV).
|
||||
|
||||
Empty/blank CSV -> empty set, meaning "self-hosting only" (resolved in ``applies``).
|
||||
Invalid tokens (regex miss) are dropped. Never raises.
|
||||
"""
|
||||
try:
|
||||
raw = (settings.checkout_hygiene_repos or "").strip()
|
||||
except Exception: # noqa: BLE001
|
||||
return set()
|
||||
if not raw:
|
||||
return set()
|
||||
out: set[str] = set()
|
||||
for tok in raw.split(","):
|
||||
t = tok.strip()
|
||||
if t and _REPO_TOKEN.match(t):
|
||||
out.add(t)
|
||||
elif t:
|
||||
logger.warning("checkout_hygiene: dropping invalid repo token %r from CSV", t)
|
||||
return out
|
||||
|
||||
|
||||
def applies(repo: str) -> bool:
|
||||
"""Whether resilient-pull hygiene is REAL for this repo (D3 / AC-6).
|
||||
|
||||
* ``checkout_hygiene_enabled=False`` -> always False (kill-switch; the hook sees
|
||||
no CHECKOUT_HYGIENE env -> bare ``git pull origin main`` 1:1 as before ORCH-112).
|
||||
* ``checkout_hygiene_repos`` (CSV) non-empty -> real only for listed repos.
|
||||
* empty CSV -> real ONLY for the self-hosting repo (``orchestrator``), mirroring
|
||||
``self_deploy_repos`` — this is a self-hosting prod-deploy-path feature, so it
|
||||
must NOT touch enduro / other repos' synchronous deploy.
|
||||
Local-only (no network), meant to be checked FIRST. Never raises -> False on error.
|
||||
"""
|
||||
try:
|
||||
if not getattr(settings, "checkout_hygiene_enabled", False):
|
||||
return False
|
||||
scope = _scope_repos()
|
||||
if scope:
|
||||
return (repo or "").strip() in scope
|
||||
# Lazy import keeps this module a leaf (no qg import at module load).
|
||||
from .qg.checks import is_self_hosting_repo
|
||||
return is_self_hosting_repo(repo)
|
||||
except Exception as e: # noqa: BLE001 - never-raise
|
||||
logger.warning("checkout_hygiene.applies error for %s: %s", repo, e)
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Env gate injected into the detached deploy-hook command (Phase B wiring)
|
||||
# ---------------------------------------------------------------------------
|
||||
def report_path_host(repo: str, work_item_id: str | None) -> str:
|
||||
"""HOST view of the ``hygiene`` sentinel path (the wrapper writes it there)."""
|
||||
from . import self_deploy
|
||||
return os.path.join(self_deploy.host_state_dir(repo, work_item_id), REPORT_NAME)
|
||||
|
||||
|
||||
def hook_env(repo: str, work_item_id: str | None) -> str:
|
||||
"""Build the env-assignment prefix injected into the detached deploy-hook command.
|
||||
|
||||
Returns ``CHECKOUT_HYGIENE=1 HYGIENE_REPORT=<host-path>`` (shlex-quoted) ONLY when
|
||||
``applies(repo)`` is True; otherwise ``""`` so the hook's ``${CHECKOUT_HYGIENE:-0}``
|
||||
guard stays 0 and the bare ``git pull`` runs (1:1 before ORCH-112). The
|
||||
``HYGIENE_REPORT`` path is the HOST view of the deploy-state dir (the host wrapper
|
||||
writes the sentinel there; the container reads it back via ``read_report``). Never
|
||||
raises -> "" (no hygiene env, the safe default).
|
||||
"""
|
||||
try:
|
||||
if not applies(repo):
|
||||
return ""
|
||||
report = report_path_host(repo, work_item_id)
|
||||
return f"CHECKOUT_HYGIENE=1 HYGIENE_REPORT={shlex.quote(report)}"
|
||||
except Exception as e: # noqa: BLE001 - never-raise -> no hygiene env
|
||||
logger.warning("checkout_hygiene.hook_env error for %s/%s: %s", repo, work_item_id, e)
|
||||
return ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Report sentinel reader (Phase C observability)
|
||||
# ---------------------------------------------------------------------------
|
||||
def read_report(repo: str, work_item_id: str | None) -> dict | None:
|
||||
"""Read the ``hygiene`` sentinel the hook wrote (container view of deploy-state).
|
||||
|
||||
The hook writes the sentinel ONLY when it detected a dirty base, body::
|
||||
|
||||
dirty=1
|
||||
<git status --porcelain lines...>
|
||||
|
||||
Returns ``{"dirty": True, "paths": [...]}`` when the sentinel exists and reports a
|
||||
dirty base; ``None`` when there is no sentinel (clean base / hygiene disabled / not
|
||||
written yet). Never raises -> None on error.
|
||||
"""
|
||||
try:
|
||||
from . import self_deploy
|
||||
p = os.path.join(self_deploy.container_state_dir(repo, work_item_id), REPORT_NAME)
|
||||
with open(p, "r", encoding="utf-8") as f:
|
||||
raw = f.read()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except Exception as e: # noqa: BLE001 - never-raise
|
||||
logger.warning("checkout_hygiene.read_report error for %s/%s: %s", repo, work_item_id, e)
|
||||
return None
|
||||
lines = raw.splitlines()
|
||||
if not any(ln.strip() == "dirty=1" for ln in lines):
|
||||
return None
|
||||
paths = [
|
||||
ln.strip() for ln in lines
|
||||
if ln.strip() and not ln.strip().startswith("dirty=")
|
||||
]
|
||||
return {"dirty": True, "paths": paths}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Best-effort Telegram alert (Phase C observability) — D5 / AC-8
|
||||
# ---------------------------------------------------------------------------
|
||||
def alert_dirty(repo: str, work_item_id: str | None, report: dict | None) -> bool:
|
||||
"""Structured log + best-effort Telegram that the deploy-base was dirty and was
|
||||
converged to ``origin/main`` before the pull (D5 / AC-8). Returns True iff an alert
|
||||
was sent. Its failure NEVER crashes the finalizer (never-raise) — observability is
|
||||
best-effort and must not block the conveyor (AC-8 FAIL is "alert crashes deploy").
|
||||
"""
|
||||
try:
|
||||
if not report or not report.get("dirty"):
|
||||
return False
|
||||
paths = report.get("paths") or []
|
||||
n = len(paths)
|
||||
logger.warning(
|
||||
"checkout_hygiene: dirty deploy-base converged to origin/main for %s/%s "
|
||||
"(%d path(s)): %s", repo, work_item_id, n, paths[:20],
|
||||
)
|
||||
from .notifications import link_for, send_telegram
|
||||
send_telegram(
|
||||
f"\U0001f9f9 {link_for(work_item_id)}: грязная deploy-база сведена к "
|
||||
f"origin/main перед прод-деплоем ({n} путь(ей) сброшено)."
|
||||
)
|
||||
return True
|
||||
except Exception as e: # noqa: BLE001 - never-raise: alert is best-effort
|
||||
logger.warning("checkout_hygiene.alert_dirty error for %s/%s: %s", repo, work_item_id, e)
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Observability snapshot for GET /queue (D3, optional)
|
||||
# ---------------------------------------------------------------------------
|
||||
def snapshot() -> dict:
|
||||
"""Read-only checkout-hygiene summary for GET /queue.
|
||||
|
||||
Additive block; existing /queue keys are untouched. never-raise -> a minimal dict
|
||||
with the flags on error.
|
||||
"""
|
||||
try:
|
||||
enabled = bool(getattr(settings, "checkout_hygiene_enabled", False))
|
||||
except Exception: # noqa: BLE001
|
||||
enabled = False
|
||||
try:
|
||||
repos_cfg = getattr(settings, "checkout_hygiene_repos", "") or ""
|
||||
except Exception: # noqa: BLE001
|
||||
repos_cfg = ""
|
||||
return {
|
||||
"enabled": enabled,
|
||||
"repos": repos_cfg,
|
||||
"scope": "csv" if (repos_cfg or "").strip() else "self-hosting-only",
|
||||
}
|
||||
@@ -290,6 +290,25 @@ class Settings(BaseSettings):
|
||||
deploy_prod_compose_profile: str = ""
|
||||
deploy_prod_prev_image_file: str = ".deploy-prev-image-prod"
|
||||
|
||||
# ORCH-112: deploy-base checkout-hygiene (resilient-pull). The self-deploy hook's
|
||||
# bare `git pull origin main` in the shared main clone blocked on a dirty working
|
||||
# tree (manual/abandoned WIP left by a failed/cancelled task — incident ORCH-111
|
||||
# from ORCH-104). The fix converges the deploy-base to a clean, current origin/main
|
||||
# (git fetch + reset --hard + a SCOPED `git clean -fd`, NEVER `-x`) BEFORE the pull,
|
||||
# gated by the CHECKOUT_HYGIENE env injected by self_deploy.build_deploy_command.
|
||||
# Pure leaf: src/checkout_hygiene.py (never-raise). Not a Quality Gate / not a stage
|
||||
# — STAGE_TRANSITIONS / QG_CHECKS / check_* / machine-verdict / DB schema / the
|
||||
# hook's exit-code contract (0/1/2, ORCH-036) are byte-for-byte untouched.
|
||||
#
|
||||
# checkout_hygiene_enabled -> kill-switch (env ORCH_CHECKOUT_HYGIENE_ENABLED).
|
||||
# False -> the hook gets no CHECKOUT_HYGIENE env ->
|
||||
# bare `git pull origin main` 1:1 as before ORCH-112.
|
||||
# checkout_hygiene_repos -> CSV scope (env ORCH_CHECKOUT_HYGIENE_REPOS). Empty
|
||||
# -> only the self-hosting repo (orchestrator). Mirrors
|
||||
# self_deploy_repos (a self-hosting prod-deploy feature).
|
||||
checkout_hygiene_enabled: bool = True
|
||||
checkout_hygiene_repos: str = ""
|
||||
|
||||
# ORCH-058: staging-image provenance before the BUILD-ONCE retag to prod.
|
||||
# Closes the INV-FRESH gap (ADR-001): the BUILD-ONCE retag (ORCH-36) promotes
|
||||
# the staging image to prod WITHOUT a rebuild, assuming the staging image is
|
||||
|
||||
@@ -214,6 +214,7 @@ async def queue():
|
||||
from . import cancel
|
||||
from . import bug_fast_track
|
||||
from . import lessons
|
||||
from . import checkout_hygiene
|
||||
from .disk_watchdog import disk_watchdog
|
||||
from .build_cache_pruner import build_cache_pruner
|
||||
return {
|
||||
@@ -254,6 +255,9 @@ async def queue():
|
||||
# kill-switch, label, scope, bug-task counts + the structural savings metric
|
||||
# (architecture stages skipped). Additive block; never-raise.
|
||||
"bug_fast_track": bug_fast_track.snapshot(),
|
||||
# ORCH-112 (D3): deploy-base checkout-hygiene observability (read-only) —
|
||||
# kill-switch + scope. Additive block; never-raise.
|
||||
"checkout_hygiene": checkout_hygiene.snapshot(),
|
||||
# ORCH-098 (FR-4 / AC-4): lessons-journal observability (read-only) —
|
||||
# kill-switch + counts by type/status + last N lessons. Additive block;
|
||||
# never-raise (snapshot() returns {"enabled": ...} minimum on error).
|
||||
|
||||
@@ -239,7 +239,7 @@ def build_deploy_command(repo: str, work_item_id: str | None, branch: str) -> li
|
||||
``expected_revision`` returns ``""`` and the env is omitted, keeping the hook's
|
||||
backward-compatible "no provenance check" behaviour (AC-5 / AC-7).
|
||||
"""
|
||||
from . import image_freshness
|
||||
from . import checkout_hygiene, image_freshness
|
||||
|
||||
host_dir = host_state_dir(repo, work_item_id)
|
||||
result_sentinel = os.path.join(host_dir, RESULT)
|
||||
@@ -262,6 +262,12 @@ def build_deploy_command(repo: str, work_item_id: str | None, branch: str) -> li
|
||||
expected_rev = image_freshness.expected_revision(repo, branch)
|
||||
if expected_rev:
|
||||
env_assignments += f" EXPECTED_REVISION={shlex.quote(expected_rev)}"
|
||||
# ORCH-112: inject CHECKOUT_HYGIENE=1 HYGIENE_REPORT=<path> only when the leaf says
|
||||
# hygiene applies (kill-switch + self-hosting scope). Empty -> the hook's
|
||||
# ${CHECKOUT_HYGIENE:-0} guard stays 0 -> bare `git pull` 1:1 as before ORCH-112.
|
||||
hygiene_env = checkout_hygiene.hook_env(repo, work_item_id)
|
||||
if hygiene_env:
|
||||
env_assignments += f" {hygiene_env}"
|
||||
inner = (
|
||||
f"cd {shlex.quote(settings.deploy_host_repo_path)} && "
|
||||
f"{env_assignments} "
|
||||
|
||||
@@ -1957,6 +1957,17 @@ def run_deploy_finalizer(job: dict):
|
||||
logger.info(
|
||||
f"Task {task_id}: deploy finalized, hook exit={code} -> deploy_status={status}"
|
||||
)
|
||||
|
||||
# ORCH-112 (D5 / AC-8): if the host hook converged a DIRTY deploy-base to
|
||||
# origin/main before the pull, surface it (structured log + best-effort Telegram).
|
||||
# never-raise — observability must never crash the finalizer.
|
||||
try:
|
||||
from . import checkout_hygiene
|
||||
report = checkout_hygiene.read_report(repo, work_item_id)
|
||||
if report:
|
||||
checkout_hygiene.alert_dirty(repo, work_item_id, report)
|
||||
except Exception as e: # noqa: BLE001 - never break the finalizer
|
||||
logger.warning("Task %s: checkout-hygiene report read failed: %s", task_id, e)
|
||||
if status == "SUCCESS" and work_item_id:
|
||||
plane_add_comment(
|
||||
work_item_id,
|
||||
|
||||
485
tests/test_deploy_checkout_hygiene.py
Normal file
485
tests/test_deploy_checkout_hygiene.py
Normal file
@@ -0,0 +1,485 @@
|
||||
"""ORCH-112: deploy-base checkout-hygiene (resilient-pull) — TC-01…TC-10.
|
||||
|
||||
Two test layers:
|
||||
|
||||
* SHELL simulation (TC-01..TC-04, TC-07) — drives the REAL
|
||||
``scripts/orchestrator-deploy-hook.sh`` in a hermetic sandbox. ``git`` is REAL
|
||||
(against a LOCAL bare "origin" — no network), while ``docker`` / ``curl`` /
|
||||
``sleep`` are PATH-shimmed stubs so no real infra is touched and prod is never
|
||||
restarted (INFRA safety). Models tests/test_deploy_hook_rollback_sim.py.
|
||||
|
||||
* UNIT (TC-05, TC-06, TC-08, TC-09, TC-10) — the ``checkout_hygiene`` leaf, the
|
||||
static safety contract of the hook (never ``-x`` / explicit excludes), the
|
||||
pipeline-invariant guard and the documentation invariant.
|
||||
|
||||
TC-01 is the MANDATORY incident-reproduction regression (ORCH-111): a dirty tracked
|
||||
edit to ``src/config.py`` over an advanced ``origin/main`` makes the bare ``git pull``
|
||||
abort with "local changes would be overwritten by merge" (RED before the fix); the
|
||||
resilient-pull hygiene converges the base and the deploy proceeds (GREEN after).
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from src import checkout_hygiene
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
HOOK = os.path.join(ROOT, "scripts", "orchestrator-deploy-hook.sh")
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
shutil.which("bash") is None or shutil.which("git") is None,
|
||||
reason="bash + git required for the deploy-hook hygiene simulation",
|
||||
)
|
||||
|
||||
# Distinctive file bodies so assertions prove WHICH version won.
|
||||
_V1 = "ORIGIN-V1\n"
|
||||
_V2 = "ORIGIN-V2-ADVANCED\n"
|
||||
_DIRTY = "DIRTY-LOCAL-WIP\n"
|
||||
|
||||
# Isolate git from any host/global config (hermetic).
|
||||
_GIT_ENV = {
|
||||
"GIT_AUTHOR_NAME": "t",
|
||||
"GIT_AUTHOR_EMAIL": "t@example.com",
|
||||
"GIT_COMMITTER_NAME": "t",
|
||||
"GIT_COMMITTER_EMAIL": "t@example.com",
|
||||
"GIT_CONFIG_GLOBAL": os.devnull,
|
||||
"GIT_CONFIG_SYSTEM": os.devnull,
|
||||
}
|
||||
|
||||
|
||||
def _git(cwd, *args):
|
||||
r = subprocess.run(
|
||||
["git", "-C", str(cwd), *args],
|
||||
capture_output=True, text=True, env={**os.environ, **_GIT_ENV},
|
||||
)
|
||||
assert r.returncode == 0, f"git {args} failed: {r.stdout}\n{r.stderr}"
|
||||
return r
|
||||
|
||||
|
||||
def _write(path, content):
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
|
||||
|
||||
def _write_exec(path, content):
|
||||
_write(path, content)
|
||||
os.chmod(path, os.stat(path).st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
||||
|
||||
|
||||
def _make_stubs(binx, prev_running=True):
|
||||
"""Healthy docker/curl/sleep stubs (no real infra; deploy always succeeds).
|
||||
|
||||
``prev_running`` controls whether ``docker compose ps -q`` returns a container id:
|
||||
True -> step 1 captures a previous image and writes PREV_IMAGE_FILE (the normal
|
||||
case); False -> no previous image is recorded (first-deploy / service-down), so the
|
||||
deploy-base stays genuinely clean at the hygiene step (exercises the no-op branch).
|
||||
"""
|
||||
ps_id = "fakecid" if prev_running else ""
|
||||
_write_exec(str(binx / "docker"), f"""#!/bin/bash
|
||||
case "$1" in
|
||||
compose)
|
||||
for a in "$@"; do [ "$a" = "ps" ] && {{ echo "{ps_id}"; exit 0; }}; done
|
||||
exit 0;;
|
||||
inspect) echo "sha256:previmage"; exit 0;;
|
||||
image) exit 0;;
|
||||
tag) exit 0;;
|
||||
*) exit 0;;
|
||||
esac
|
||||
""")
|
||||
# curl: ALWAYS healthy -> deploy health-check passes immediately -> exit 0.
|
||||
_write_exec(str(binx / "curl"), """#!/bin/bash
|
||||
iscode=""
|
||||
for a in "$@"; do [ "$a" = "-w" ] && iscode=1; done
|
||||
[ -n "$iscode" ] && echo "200" || echo '{"status":"ok"}'
|
||||
exit 0
|
||||
""")
|
||||
_write_exec(str(binx / "sleep"), "#!/bin/bash\nexit 0\n")
|
||||
|
||||
|
||||
def _seed_origin_and_clone(tmp_path):
|
||||
"""Build a local bare origin (at V2) + a deploy-base clone (at V1).
|
||||
|
||||
Returns ``repo`` (the deploy-base path). The clone is one commit BEHIND origin so
|
||||
that, with a conflicting dirty edit to src/config.py, a bare ``git pull`` would
|
||||
abort (the exact ORCH-111 incident), while hygiene's reset --hard converges it.
|
||||
"""
|
||||
work = tmp_path / "work"
|
||||
work.mkdir()
|
||||
_write(str(work / "src" / "config.py"), _V1)
|
||||
_write(str(work / ".gitignore"), ".env\ndata/\n*.db\nbuild/\n")
|
||||
_git(work, "init", "-q")
|
||||
_git(work, "add", "-A")
|
||||
_git(work, "commit", "-q", "-m", "v1")
|
||||
_git(work, "branch", "-M", "main")
|
||||
|
||||
origin = tmp_path / "origin.git"
|
||||
_git(tmp_path, "init", "-q", "--bare", str(origin))
|
||||
_git(work, "remote", "add", "origin", str(origin))
|
||||
_git(work, "push", "-q", "-u", "origin", "main")
|
||||
|
||||
repo = tmp_path / "repo"
|
||||
_git(tmp_path, "clone", "-q", str(origin), str(repo))
|
||||
|
||||
# Advance origin/main to V2 (touches the SAME file we will dirty locally).
|
||||
_write(str(work / "src" / "config.py"), _V2)
|
||||
_git(work, "commit", "-q", "-am", "v2")
|
||||
_git(work, "push", "-q", "origin", "main")
|
||||
# Make repo's remote-tracking ref aware of V2's existence is the hook's job
|
||||
# (it runs `git fetch`); leave repo at V1 deliberately.
|
||||
return repo
|
||||
|
||||
|
||||
def _run_hook(repo, tmp_path, hygiene="1", extra_env=None, prev_running=True):
|
||||
"""Run the real hook in --deploy mode against ``repo`` with stubbed infra."""
|
||||
binx = tmp_path / "bin"
|
||||
if not binx.exists():
|
||||
binx.mkdir()
|
||||
_make_stubs(binx, prev_running=prev_running)
|
||||
state = tmp_path / "state"
|
||||
state.mkdir(exist_ok=True)
|
||||
env = {
|
||||
**os.environ,
|
||||
**_GIT_ENV,
|
||||
"PATH": f"{binx}:{os.environ['PATH']}",
|
||||
"REPO": str(repo),
|
||||
"LOG": str(state / "hook.log"),
|
||||
"PREV_IMAGE_FILE": str(repo / ".deploy-prev-image-prod"),
|
||||
"COMPOSE_PROFILE": "",
|
||||
"TARGET_SERVICE": "orchestrator",
|
||||
"TARGET_PORT": "8500",
|
||||
}
|
||||
if hygiene is not None:
|
||||
env["CHECKOUT_HYGIENE"] = hygiene
|
||||
env["HYGIENE_REPORT"] = str(state / "hygiene")
|
||||
if extra_env:
|
||||
env.update(extra_env)
|
||||
return subprocess.run(
|
||||
["bash", HOOK, "--deploy"], env=env, capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
|
||||
|
||||
def _porcelain(repo):
|
||||
r = subprocess.run(
|
||||
["git", "-C", str(repo), "status", "--porcelain"],
|
||||
capture_output=True, text=True, env={**os.environ, **_GIT_ENV},
|
||||
)
|
||||
return r.stdout.strip()
|
||||
|
||||
|
||||
def _wip_dirt(repo):
|
||||
"""Porcelain output MINUS the intentionally-preserved deploy artefacts.
|
||||
|
||||
After hygiene, the deploy-base is converged to origin/main but the rollback/log
|
||||
artefacts (.deploy-prev-image-* / deploy-hook.log) are legitimately untracked-and-
|
||||
preserved (NFR-2). This returns ONLY the *real* residual dirt (a non-empty result
|
||||
means a tracked edit survived or WIP was not cleaned)."""
|
||||
lines = []
|
||||
for ln in _porcelain(repo).splitlines():
|
||||
name = ln[3:] if len(ln) > 3 else ""
|
||||
if name.startswith(".deploy-prev-image-") or name == "deploy-hook.log":
|
||||
continue
|
||||
lines.append(ln)
|
||||
return "\n".join(lines).strip()
|
||||
|
||||
|
||||
def _head_config(repo):
|
||||
with open(repo / "src" / "config.py", encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TC-01 — MANDATORY regression (red->green): dirty tracked edit + advanced origin
|
||||
# ===========================================================================
|
||||
def test_tc01_dirty_tracked_edit_converges_and_deploys(tmp_path):
|
||||
repo = _seed_origin_and_clone(tmp_path)
|
||||
# Dirty the SAME tracked file origin advanced -> a bare `git pull` would abort.
|
||||
_write(str(repo / "src" / "config.py"), _DIRTY)
|
||||
# Untracked WIP left behind too (failed/cancelled task residue).
|
||||
_write(str(repo / "scripts" / "install_lite.py"), "# wip\n")
|
||||
|
||||
proc = _run_hook(repo, tmp_path, hygiene="1")
|
||||
|
||||
assert proc.returncode == 0, (
|
||||
"resilient-pull must converge a dirty base and let the deploy proceed "
|
||||
f"(stdout={proc.stdout}\nstderr={proc.stderr})"
|
||||
)
|
||||
# Base converged to the ADVANCED origin/main (dirty local edit discarded).
|
||||
assert _head_config(repo) == _V2
|
||||
# No real WIP remains (tracked edit reset, untracked WIP cleaned); the rollback
|
||||
# snapshot .deploy-prev-image-prod is legitimately preserved (NFR-2), so we check
|
||||
# the residual dirt MINUS the preserved artefacts.
|
||||
assert _wip_dirt(repo) == ""
|
||||
assert not (repo / "scripts" / "install_lite.py").exists()
|
||||
out = proc.stdout + proc.stderr
|
||||
assert "HYGIENE" in out
|
||||
|
||||
|
||||
def test_tc01b_bare_pull_aborts_without_hygiene_documents_incident(tmp_path):
|
||||
"""ORCH-111 reproduction: WITHOUT hygiene the same dirty base aborts the pull."""
|
||||
repo = _seed_origin_and_clone(tmp_path)
|
||||
_write(str(repo / "src" / "config.py"), _DIRTY)
|
||||
|
||||
proc = _run_hook(repo, tmp_path, hygiene=None) # CHECKOUT_HYGIENE unset
|
||||
|
||||
assert proc.returncode != 0, "bare `git pull` must abort on the conflicting dirty edit"
|
||||
log = (tmp_path / "state" / "hook.log").read_text(encoding="utf-8")
|
||||
assert "would be overwritten by merge" in (log + proc.stdout + proc.stderr)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TC-02 — untracked WIP files do not block and do not leak into the deploy
|
||||
# ===========================================================================
|
||||
def test_tc02_untracked_wip_does_not_block(tmp_path):
|
||||
repo = _seed_origin_and_clone(tmp_path)
|
||||
for rel in (
|
||||
"scripts/install_lite.py",
|
||||
"tests/test_install_lite.py",
|
||||
"docs/deployment/lite-install.example.yaml",
|
||||
):
|
||||
_write(str(repo / rel), "# abandoned WIP\n")
|
||||
|
||||
proc = _run_hook(repo, tmp_path, hygiene="1")
|
||||
|
||||
assert proc.returncode == 0, f"{proc.stdout}\n{proc.stderr}"
|
||||
assert _wip_dirt(repo) == ""
|
||||
for rel in (
|
||||
"scripts/install_lite.py",
|
||||
"tests/test_install_lite.py",
|
||||
"docs/deployment/lite-install.example.yaml",
|
||||
):
|
||||
assert not (repo / rel).exists(), f"{rel} must be cleaned, not leaked into deploy"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TC-03 — preservation of rollback/log/gitignored/sibling artefacts (NFR-2)
|
||||
# ===========================================================================
|
||||
def test_tc03_preserves_rollback_and_sibling_artifacts(tmp_path):
|
||||
repo = _seed_origin_and_clone(tmp_path)
|
||||
_write(str(repo / "src" / "config.py"), _DIRTY) # force the hygiene path
|
||||
|
||||
# In-$REPO artefacts that MUST survive (untracked, NOT gitignored).
|
||||
_write(str(repo / ".deploy-prev-image-staging"), "sha256:stagingprev\n")
|
||||
_write(str(repo / "deploy-hook.log"), "audit line\n")
|
||||
# gitignored prod secrets / DB — must survive `git clean -fd` (NO -x).
|
||||
_write(str(repo / ".env"), "ORCH_SECRET=keepme\n")
|
||||
_write(str(repo / "data" / "orchestrator.db"), "sqlite-bytes\n")
|
||||
# .git internal worktree admin record — git clean never touches .git/.
|
||||
_write(str(repo / ".git" / "worktrees" / "wt1" / "HEAD"), "ref: refs/heads/x\n")
|
||||
# Sibling state under the PARENT of $REPO — outside the clean scope.
|
||||
_write(str(tmp_path / ".deploy-state-orchestrator" / "ORCH-112" / "result"), "0\n")
|
||||
_write(str(tmp_path / ".merge-lease-orchestrator.json"), '{"branch":"x"}\n')
|
||||
|
||||
proc = _run_hook(repo, tmp_path, hygiene="1")
|
||||
assert proc.returncode == 0, f"{proc.stdout}\n{proc.stderr}"
|
||||
|
||||
# Rollback snapshot freshly written by step 1 (PREV_IMAGE_FILE) survived hygiene.
|
||||
assert (repo / ".deploy-prev-image-prod").is_file()
|
||||
assert (repo / ".deploy-prev-image-prod").read_text().strip() != ""
|
||||
# Wildcard-excluded sibling prev-image + log survived.
|
||||
assert (repo / ".deploy-prev-image-staging").read_text() == "sha256:stagingprev\n"
|
||||
assert (repo / "deploy-hook.log").read_text() == "audit line\n"
|
||||
# gitignored secrets/DB survived (proves NO -x at runtime).
|
||||
assert (repo / ".env").read_text() == "ORCH_SECRET=keepme\n"
|
||||
assert (repo / "data" / "orchestrator.db").read_text() == "sqlite-bytes\n"
|
||||
# .git internal + sibling state untouched.
|
||||
assert (repo / ".git" / "worktrees" / "wt1" / "HEAD").is_file()
|
||||
assert (tmp_path / ".deploy-state-orchestrator" / "ORCH-112" / "result").is_file()
|
||||
assert (tmp_path / ".merge-lease-orchestrator.json").is_file()
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TC-04 — happy-path: genuinely clean base -> hygiene no-op + plain fast-forward.
|
||||
# Uses prev_running=False so step 1 records NO prev-image, leaving the base clean at
|
||||
# the hygiene step (no untracked artefact) — the no-op `else` branch is exercised and
|
||||
# the deploy reduces to the plain `git pull` fast-forward (exit-codes byte-for-byte).
|
||||
# ===========================================================================
|
||||
def test_tc04_clean_base_fast_forwards_no_op_hygiene(tmp_path):
|
||||
repo = _seed_origin_and_clone(tmp_path) # repo is CLEAN, just behind origin
|
||||
|
||||
proc = _run_hook(repo, tmp_path, hygiene="1", prev_running=False)
|
||||
|
||||
assert proc.returncode == 0, f"{proc.stdout}\n{proc.stderr}"
|
||||
log = (tmp_path / "state" / "hook.log").read_text(encoding="utf-8")
|
||||
assert "deploy-base already clean (no-op)" in log
|
||||
assert "dirty deploy-base detected" not in log
|
||||
# Plain fast-forward brought origin/main's V2.
|
||||
assert _head_config(repo) == _V2
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TC-07 — convergence after cancel/failed: leftovers cleared, next deploy clean
|
||||
# ===========================================================================
|
||||
def test_tc07_convergence_then_next_deploy_is_clean(tmp_path):
|
||||
repo = _seed_origin_and_clone(tmp_path)
|
||||
# Leftovers from a failed/cancelled task: dirty tracked + untracked WIP.
|
||||
_write(str(repo / "src" / "config.py"), _DIRTY)
|
||||
_write(str(repo / "tests" / "test_install_lite.py"), "# wip\n")
|
||||
|
||||
first = _run_hook(repo, tmp_path, hygiene="1")
|
||||
assert first.returncode == 0, f"{first.stdout}\n{first.stderr}"
|
||||
assert _wip_dirt(repo) == "" # base converged, no WIP residue
|
||||
assert _head_config(repo) == _V2
|
||||
|
||||
# A subsequent self-deploy proceeds without manual intervention (no WIP to block it).
|
||||
second = _run_hook(repo, tmp_path, hygiene="1")
|
||||
assert second.returncode == 0, f"{second.stdout}\n{second.stderr}"
|
||||
assert _wip_dirt(repo) == ""
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TC-05 — self-hosting safety + static hook safety contract (never -x / excludes)
|
||||
# ===========================================================================
|
||||
def _hook_code_lines():
|
||||
"""Non-comment, non-blank lines of the hook (so a comment mentioning `-x` or
|
||||
`exit` for documentation does not trip the static safety asserts)."""
|
||||
out = []
|
||||
for ln in open(HOOK, encoding="utf-8").read().splitlines():
|
||||
s = ln.strip()
|
||||
if not s or s.startswith("#"):
|
||||
continue
|
||||
out.append(ln)
|
||||
return out
|
||||
|
||||
|
||||
def test_tc05_hook_clean_is_never_destructive():
|
||||
text = open(HOOK, encoding="utf-8").read()
|
||||
code = "\n".join(_hook_code_lines())
|
||||
assert "CHECKOUT_HYGIENE" in text, "hygiene block must exist in the hook"
|
||||
# INV-HYGIENE-1: the hook's only `git clean` is `-fd`, NEVER `-x` (which would
|
||||
# delete gitignored .env / data/*.db / build/). Checked against CODE only.
|
||||
assert "git clean -fd" in code
|
||||
assert "-x" not in code # no -x / -xfd / -fdx in any executable line
|
||||
# INV-HYGIENE-2: explicit excludes for the untracked-but-not-ignored artefacts.
|
||||
assert "-e '.deploy-prev-image-*'" in code
|
||||
assert "-e 'deploy-hook.log'" in code
|
||||
# Converge to the authoritative remote, never a local guess.
|
||||
assert "git reset --hard origin/main" in code
|
||||
# Self-hosting safety: the hygiene path never pushes/force-pushes the remote.
|
||||
assert "push --force" not in code and "push -f " not in code
|
||||
|
||||
|
||||
def test_tc05_leaf_is_a_pure_leaf():
|
||||
"""checkout_hygiene must not import stage_engine / launcher at module load."""
|
||||
src = open(os.path.join(ROOT, "src", "checkout_hygiene.py"), encoding="utf-8").read()
|
||||
import_lines = [
|
||||
ln for ln in src.splitlines()
|
||||
if ln.startswith("import ") or ln.startswith("from ")
|
||||
]
|
||||
joined = "\n".join(import_lines)
|
||||
assert "stage_engine" not in joined
|
||||
assert "launcher" not in joined
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TC-06 — kill-switch + repo scope (applies / hook_env)
|
||||
# ===========================================================================
|
||||
def test_tc06_kill_switch_off_is_inert(monkeypatch):
|
||||
from src.config import settings
|
||||
monkeypatch.setattr(settings, "checkout_hygiene_enabled", False)
|
||||
assert checkout_hygiene.applies("orchestrator") is False
|
||||
assert checkout_hygiene.hook_env("orchestrator", "ORCH-112") == ""
|
||||
|
||||
|
||||
def test_tc06_empty_csv_is_self_hosting_only(monkeypatch):
|
||||
from src.config import settings
|
||||
monkeypatch.setattr(settings, "checkout_hygiene_enabled", True)
|
||||
monkeypatch.setattr(settings, "checkout_hygiene_repos", "")
|
||||
assert checkout_hygiene.applies("orchestrator") is True
|
||||
assert checkout_hygiene.applies("enduro-trails") is False
|
||||
env = checkout_hygiene.hook_env("orchestrator", "ORCH-112")
|
||||
assert env.startswith("CHECKOUT_HYGIENE=1 ")
|
||||
assert "HYGIENE_REPORT=" in env
|
||||
# A non-self repo gets no hygiene env (other repos unaffected).
|
||||
assert checkout_hygiene.hook_env("enduro-trails", "ET-1") == ""
|
||||
|
||||
|
||||
def test_tc06_csv_scope_limits_repos(monkeypatch):
|
||||
from src.config import settings
|
||||
monkeypatch.setattr(settings, "checkout_hygiene_enabled", True)
|
||||
monkeypatch.setattr(settings, "checkout_hygiene_repos", "alpha, beta")
|
||||
assert checkout_hygiene.applies("alpha") is True
|
||||
assert checkout_hygiene.applies("beta") is True
|
||||
assert checkout_hygiene.applies("orchestrator") is False
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TC-08 — observability: read_report / alert_dirty never-raise
|
||||
# ===========================================================================
|
||||
def test_tc08_read_report_none_when_absent(monkeypatch, tmp_path):
|
||||
from src.config import settings
|
||||
monkeypatch.setattr(settings, "repos_dir", str(tmp_path))
|
||||
assert checkout_hygiene.read_report("orchestrator", "ORCH-112") is None
|
||||
|
||||
|
||||
def test_tc08_read_report_parses_dirty_sentinel(monkeypatch, tmp_path):
|
||||
from src import self_deploy
|
||||
from src.config import settings
|
||||
monkeypatch.setattr(settings, "repos_dir", str(tmp_path))
|
||||
d = self_deploy.container_state_dir("orchestrator", "ORCH-112")
|
||||
os.makedirs(d, exist_ok=True)
|
||||
_write(os.path.join(d, "hygiene"), "dirty=1\n M src/config.py\n?? scripts/x.py\n")
|
||||
rep = checkout_hygiene.read_report("orchestrator", "ORCH-112")
|
||||
assert rep == {"dirty": True, "paths": ["M src/config.py", "?? scripts/x.py"]}
|
||||
|
||||
|
||||
def test_tc08_alert_dirty_never_raises_on_send_failure(monkeypatch):
|
||||
import src.notifications as notifications
|
||||
|
||||
def boom(*a, **k):
|
||||
raise RuntimeError("telegram down")
|
||||
|
||||
monkeypatch.setattr(notifications, "send_telegram", boom)
|
||||
# Must swallow the error (best-effort) and NOT crash the finalizer.
|
||||
assert checkout_hygiene.alert_dirty(
|
||||
"orchestrator", "ORCH-112", {"dirty": True, "paths": ["x"]}
|
||||
) is False
|
||||
# No report / not dirty -> no alert, no raise.
|
||||
assert checkout_hygiene.alert_dirty("orchestrator", "ORCH-112", None) is False
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TC-09 — pipeline invariant: STAGE_TRANSITIONS / QG_CHECKS / exit-codes untouched
|
||||
# ===========================================================================
|
||||
def test_tc09_pipeline_contracts_untouched():
|
||||
from src import stages
|
||||
from src.qg import checks
|
||||
# The hygiene feature is NOT a stage and NOT a QG check.
|
||||
assert "checkout_hygiene" not in {
|
||||
k for tr in stages.STAGE_TRANSITIONS.values() for k in (tr if isinstance(tr, dict) else {})
|
||||
}
|
||||
assert not any("hygiene" in name for name in checks.QG_CHECKS)
|
||||
|
||||
|
||||
def test_tc09_hook_exit_code_contract_intact():
|
||||
text = open(HOOK, encoding="utf-8").read()
|
||||
# The hook still maps to the 0/1/2 contract (ORCH-036).
|
||||
assert "exit 0" in text and "exit 1" in text and "exit 2" in text
|
||||
# The hygiene block itself never emits an `exit` statement (best-effort,
|
||||
# never-break). Inspect only the CODE lines of the 2a block (a comment that
|
||||
# mentions "exit-codes" must not trip this).
|
||||
block = text.split("# 2a.", 1)[1].split("# 2.", 1)[0]
|
||||
code_lines = [
|
||||
ln for ln in block.splitlines()
|
||||
if ln.strip() and not ln.strip().startswith("#")
|
||||
]
|
||||
for ln in code_lines:
|
||||
assert not ln.strip().startswith("exit"), (
|
||||
f"hygiene block must never change the hook exit-code: {ln!r}"
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TC-10 — documentation invariant (golden source)
|
||||
# ===========================================================================
|
||||
def test_tc10_docs_state_deploy_base_invariant():
|
||||
infra = open(os.path.join(ROOT, "docs", "operations", "INFRA.md"), encoding="utf-8").read()
|
||||
readme = open(os.path.join(ROOT, "docs", "architecture", "README.md"), encoding="utf-8").read()
|
||||
for doc in (infra, readme):
|
||||
assert "ORCH-112" in doc
|
||||
assert "deploy/worktree-management база" in doc
|
||||
assert "workspace" in doc
|
||||
Reference in New Issue
Block a user