---
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) — инварианты не нарушаются.
