---
work_item: ORCH-094
stage: architecture
author_agent: architect
status: proposed
created_at: 2026-06-09
model_used: claude-opus-4-8
---

# ADR-001: Terminal-window-aware гард выставления deploy-фазовых статусов Plane

Work Item: **ORCH-094** — терминальная (done) задача флаппит deploy-статусы в Plane
(`Awaiting Deploy ⟷ Monitoring after Deploy`), не держит `Done`.
Стадия: **architecture**
Сквозная регистрация: **`docs/architecture/adr/adr-0028-terminal-window-aware-deploy-status-guard.md`**
(кросс-каттинг: правит общие сеттеры `plane_sync` + переупорядочивает маркированный блок
`next_stage == "done"` ORCH-021/066).

## Статус
Proposed

## Контекст

Сверено по коду ветки `feature/ORCH-094-…`:

- **Три code-писателя deploy-фазовых статусов** — все в `src/stage_engine.py`, все вызывают
  тонкие сеттеры `src/plane_sync.py`, которые делегируют в общий `_set_issue_state_direct`
  (PATCH issue.state; never-raise; **БД-стадию не читает**):
  - `set_issue_awaiting_deploy` (Phase A, `stage_engine.py:1218`),
  - `set_issue_deploying` (Phase B, `stage_engine.py:1316`),
  - `set_issue_monitoring` (terminal-sync `deploy → done` для self-hosting, `stage_engine.py:404`).
  - `set_issue_done` (`plane_sync.py:913`) — **терминальная цель**, отдельно.
- **Критический факт ordering'а:** в `advance_stage` строка **369** `update_task_stage(task_id, "done")`
  пишет `tasks.stage='done'` **РАНЬШЕ**, чем строка **404** `set_issue_monitoring(...)`. То есть в
  момент **легитимного** первого выставления `Monitoring after Deploy` задача в БД **уже `done`**.
  Пост-деплой-окно ORCH-021 — это by-design индикация поверх уже-терминальной (`done`) задачи
  («ответственность ЗА `done`»). ⇒ **наивный гард «stage==done → редирект на Done» подавил бы
  легитимный `Monitoring` → регресс AC-4.**
- **Арм пост-деплой-монитора** (`stage_engine.py:431` → `post_deploy.arm_monitor`) выполняется
  **ПОСЛЕ** строки 404. Sentinel `ARMED` пишется в `arm_monitor`; окно закрывается sentinel'ом
  `DONE` (`post_deploy.mark_done`); идемпотентный страж `has_marker(...DONE)` в
  `run_post_deploy_monitor` (~1729).
- **Симптом (верифицирован живьём на ORCH-061, task 47, done с 07.06):** Plane не держит `Done` —
  непрерывный флапп `Awaiting ⟷ Monitoring` парами каждые ~сек, 273 активности, само не затихает.
  В БД **нет активного post-deploy-monitor** для task 47 (окно 15 мин давно закрыто); реконсилятор
  F-1 пропускает `done`/`cancelled`, F-2 опрашивает только `[to_analyse, approved, rejected]` —
  механизма «привести застрявшую на deploy-статусе done-задачу обратно к Done» нет. Актор всех 273
  переходов — бот-токен орка (`daf4d3f4-…`), т.е. PATCH-и шлёт **что-то под токеном орка**, не
  привязанное к активной task/job. Точный актор подлежит инструментальной локализации (FR-1,
  developer); фикс должен быть **буфером, гасящим маятник на стороне орка независимо от актора**.

**Почему «как есть» не годится:** сеттеры deploy-статусов терминал-слепы — любой повторный вызов
(стейл-job, двойной webhook, неизвестный внутренний путь под бот-токеном) перезаписывает `Done`
обратно на промежуточный deploy-статус, и наоборот, бесконечно. Нет ни идемпотентного схождения к
`Done` для терминальной задачи, ни наблюдаемости «кто/почему» ставит статус.

## Решение

### Сводка

Вводим **единый terminal-window-aware гард на самом низком чокпоинте** — на входе трёх
deploy-фазовых сеттеров `plane_sync`. Решение принимает **новый leaf-модуль
`src/deploy_status_guard.py`** (чистая, never-raise, config-gated логика; по образцу
`serial_gate.py`/`labels.py`/`cancel.py`), сеттеры лишь исполняют вердикт. Ключевой инвариант:
**deploy-фазовый статус легитимен ⇔ задача нетерминальна ИЛИ (`done` И активно пост-деплой-окно)**;
иначе — идемпотентное схождение к `Done`. Чтобы легитимный первый `Monitoring` на строке 404
проходил, **арм-блок переносится перед terminal-sync-блоком** (предикат «окно активно» становится
истинным до выставления `Monitoring`). Всё под kill-switch, аддитивно, в зоне self-hosting; реестры
конвейера не тронуты.

### D1 — Где гард: единый чокпоинт в deploy-фазовых сеттерах `plane_sync`

Гард ставится на входе **`set_issue_awaiting_deploy` / `set_issue_deploying` / `set_issue_monitoring`**
(а НЕ в caller'ах `stage_engine`). Это перехватывает **любой** путь к этим статусам — известные
(stage_engine), будущие и **неизвестный актор под бот-токеном** (если он проходит через код орка) —
одной точкой. `set_issue_done` **не гардится** (это цель схождения). Привязка: **FR-2, BR-1, BR-2**.

> Альтернатива «гард в caller'ах stage_engine» отвергнута: не ловит неизвестный/стейл путь, который
> и есть подозреваемый источник 061-флаппа; размазывает инвариант по трём местам. См. «Альтернативы».

### D2 — Предикат легитимности: терминал **И окно**, не только стадия

Вердикт `deploy_status_guard.decide(work_item_id, target_status) -> ALLOW | CONVERGE_DONE | SUPPRESS`:

1. `not settings.deploy_status_guard_enabled` → **ALLOW** (kill-switch off ⇒ поведение 1:1).
2. `task = <lookup по work_item_id>`; `task is None` → **ALLOW** (чужой/не наш issue — не вмешиваемся).
3. `not deploy_status_guard.applies(task.repo)` → **ALLOW** (не-self репо ⇒ нулевая регрессия; для них
   `Monitoring`/`Awaiting`/`Deploying` и так не выставляются — terminal-sync идёт сразу в `Done`).
4. `stage = task.stage`; `stage NOT IN ('done','cancelled')` → **ALLOW** (нетерминальная задача —
   легитимный рабочий deploy-цикл; **AC-4**).
5. `stage == 'cancelled'` → **SUPPRESS** (не штампуем deploy-статус поверх терминала `cancelled`;
   cancel-flow ORCH-090 уже привёл Plane к своему терминалу — гард лишь не затирает его).
6. `stage == 'done'`:
   - `target == 'monitoring'` **И** `post_deploy.window_active(repo, work_item_id)` → **ALLOW**
     (легитимное пост-деплой-окно — `Monitoring` корректен; **AC-4**);
   - иначе → **CONVERGE_DONE** (для `done` `Awaiting`/`Deploying` всегда спуриозны — Phase A/B
     случаются строго **до** `deploy → done`; и `Monitoring` при закрытом/неарм'ленном окне —
     спуриозен, как 061).
7. **Любое исключение / невозможность определить стадию** → **ALLOW** + `logger.warning`
   (never-raise, fail-safe к прежнему поведению; **NFR-1**). БД-чтение локальное (SQLite) и надёжное —
   в штатном случае стадия читается, маятник не возникает.

Сеттер исполняет вердикт: `ALLOW` → штатный PATCH; `CONVERGE_DONE` → `set_issue_done(work_item_id)`
(идемпотентно — уже-`Done` ⇒ no-op PATCH-эквивалент); `SUPPRESS` → ничего не патчим. Привязка:
**FR-2, BR-1, BR-2, AC-1, AC-2, AC-4**.

**Новый helper** `post_deploy.window_active(repo, wi) -> bool` = `has_marker(ARMED) and not
has_marker(DONE)` (never-raise; restart-safe — sentinel'ы на диске переживают рестарт; **NFR-4**).

### D3 — Перенос арм-блока перед terminal-sync (чтобы D2 пропускал легитимный первый `Monitoring`)

В `advance_stage`, внутри ветки `next_stage == "done"`, **арм-блок** (`post_deploy.arm_monitor`,
сейчас стр. 431) перемещается **выше** terminal-sync-блока (`set_issue_monitoring`, стр. 404). После
переноса в момент строки 404: `ARMED` уже записан, `DONE` отсутствует ⇒ `window_active==True` ⇒
вердикт **ALLOW** ⇒ легитимный `Monitoring` проходит как раньше. Re-drive `deploy → done` **после**
закрытия окна (`DONE` присутствует) ⇒ `window_active==False` ⇒ **CONVERGE_DONE** (не воскрешает
`Monitoring`).

Перенос безопасен: `arm_monitor` лишь пишет sentinel + ставит отложенный job — не зависит ни от
Plane-статуса, ни от merge-lease (release остаётся после terminal-sync). Инварианты ORCH-021
(идемпотентный арм по `ARMED`) и ORCH-066 (`deploy → done` для self ⇒ `Monitoring`, не `Done`)
сохранены. Привязка: **AC-4, BR-5**; маркеры `ORCH-021`/`ORCH-066` (прочитаны: `06-adr/ADR-001`,
`adr-0010`).

> Альтернатива «bypass-флаг `force=True` на доверенном вызове 404 вместо переноса» отвергнута: плодит
> два определения «легитимности» и доверенный обход; перенос оставляет **один** предикат «окно активно».

### D4 — Харднинг пост-деплой-монитора: нет «зомби»-тиков/PATCH после закрытия окна

`run_post_deploy_monitor` (`stage_engine.py` ~1698): сохранить существующий идемпотентный страж
`has_marker(...DONE)` (~1729; первым — ранний `return` без PATCH/реэнкью). Аддитивно: тик
**no-op без PATCH и без перепостановки**, если задача стала терминальной аномально (`stage ==
'cancelled'` мид-окно → закрыть окно `mark_done`, без статус-PATCH). Перепостановка тика остаётся
строго при `HEALTHY and ticks < budget` — тики **привязаны к активному job'у** (тик и есть job; нет
job → нет тика). После закрытия окна (`DONE`) или исчерпания бюджета — **0 последующих** статус-PATCH;
любой стейл-вызов `set_issue_monitoring` теперь добивается гардом D2 (`window_active==False` →
CONVERGE_DONE). `arm_monitor` уже идемпотентен по `ARMED` (повторный арм done-задачи → no-op). Привязка:
**FR-3, BR-3, BR-4, AC-3, NFR-4**.

### D5 — Наблюдаемость «кто/почему» (FR-4)

Каждый вердикт гарда логируется структурно одной записью: `work_item`, `caller` (короткая причина —
аддитивный BC-kwarg `reason: str | None = None` у трёх сеттеров; call-site передаёт напр.
`"advance:deploy->done"`/`"phase_a"`/`"phase_b"`/`"monitor-tick"`), `target_status`, `db_stage`,
`window_active`, итоговый вердикт (`ALLOW`/`CONVERGE_DONE`/`SUPPRESS`). Подавление/схождение
(`CONVERGE_DONE`/`SUPPRESS`) логируется **явно** («что подавили и почему»). Достаточно, чтобы по
логу однозначно атрибутировать будущий флапп. Привязка: **FR-4, BR-6, AC-5**.

### D6 — Обратимость, скоуп, флаги (FR-5)

`src/config.py` (по образцу ORCH-088/090):
- `deploy_status_guard_enabled: bool = True` — env `ORCH_DEPLOY_STATUS_GUARD_ENABLED` (kill-switch;
  `False` → сеттеры терминал-слепы, поведение **1:1** прежнее).
- `deploy_status_guard_repos: str = ""` — env `ORCH_DEPLOY_STATUS_GUARD_REPOS` (CSV; **пусто →
  self-hosting only**). `applies(repo)` (локальный, без сети) — единственная точка скоупа.

Дефолт `enabled=True` + `repos=""` ⇒ активен только для self-hosting (`orchestrator`), где deploy-фазовые
статусы вообще выставляются; не-self репо (enduro-trails) гард не трогает (D2 шаг 3). Привязка: **NFR-3,
BR-5, FR-5, AC-4, AC-5**.

### D7 — Что НЕ трогаем (инварианты)

`STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / machine-verdict ключи
(`deploy_status:`/`staging_status:`/`security_status:`) — **байт-в-байт**. Схема БД — **без миграции**
(гард читает существующую `tasks.stage`; окно — существующие sentinel'ы `post_deploy.py`; привязка к
job — существующая таблица `jobs`). `main`/force-push/прод-контейнер/detached-деплой — **не трогаются**.
Рабочий критический путь self-deploy (Phase A→B→C, merge-gate, freeze-на-DEGRADED ORCH-088) —
сохранён 1:1. Реконсилятор F-1/F-2 — **без изменений** (гард на сеттере субсумирует «sync → Done»:
любой путь, дёрнувший deploy-сеттер для done-задачи, сходится к `Done`). Привязка: **NFR-2, NFR-5, AC-5**.

### D8 — Лукап задачи по `work_item_id` (реализационная заметка для developer)

Сеттеры принимают `work_item_id` (напр. `"ORCH-061"`). В `src/db.py` существующий
`get_task_by_plane_id` матчит `plane_id`/`plane_issue_id` (UUID-ы), **не** человекочитаемый
`work_item_id`. Developer добавляет минимальный **read-only** аксессор
`get_task_by_work_item_id(work_item_id)` (`SELECT * FROM tasks WHERE work_item_id = ?`; живой ряд
матчит точно — тумбстоны ORCH-090 имеют суффикс `#cancelled-<id>`), **без изменения схемы**. Один
локальный SELECT отдаёт и `repo`, и `stage` для D2.

## Альтернативы

- **Гард в caller'ах `stage_engine` (а не в сеттерах)** — отвергнуто: не ловит неизвестный/стейл
  актор под бот-токеном (вероятный источник 061-флаппа), размазывает инвариант по трём врезкам,
  слабее как буфер BR-2 «сходимость из любого пути».
- **Наивный гард «stage==done → редирект на Done» (без предиката окна)** — отвергнуто: подавляет
  легитимный пост-деплой `Monitoring` (он by-design поверх уже-`done` задачи, стр. 369 < 404) ⇒
  прямой регресс **AC-4**.
- **Bypass-флаг `force=True` на доверенном вызове 404** (вместо переноса арм-блока) — отвергнуто:
  два определения легитимности + доверенный обход; перенос даёт один предикат «окно активно».
- **Активная сходимость в реконсиляторе (F-2 опрашивает Awaiting/Monitoring → set_issue_done)** —
  отвергнуто как **основной** механизм (лишний Plane-polling, правка маркированного F-2). Гард на
  сеттере уже гасит непрерывный флапп (каждый вызов актора сходится к `Done` за один цикл). Возможен
  как **необязательный** follow-up для разовой зачистки quiescent-застрявшего статуса (вне scope —
  такой кейс чинится разовым ручным sync; наблюдаемый дефект — непрерывный флапп, который буфер
  покрывает).
- **Колонка-маркер в `tasks` для состояния окна** — отвергнуто: миграция на проде; sentinel'ы
  `post_deploy.py` уже restart-safe (как ORCH-021/036).

## Последствия

- **+** Терминальная (`done`) задача стабильно держит `Done`: любой deploy-сеттер для неё сходится к
  `Done` идемпотентно, маятник гаснет за один цикл независимо от актора (буфер BR-1/BR-2, AC-1/AC-2).
- **+** Легитимный пост-деплой `Monitoring` сохранён точно (предикат «окно активно» + перенос
  арм-блока); рабочий deploy-цикл 1:1 (AC-4).
- **+** Наблюдаемость: лог однозначно атрибутирует «кто/почему» при будущем флаппе (AC-5).
- **+** Единый низкий чокпоинт ловит и неизвестный внутренний путь под бот-токеном.
- **−** Один локальный SELECT (`tasks`) на каждый deploy-фазовый PATCH-вызов self-репо. Митигейшн:
  читается тот же ряд, что даёт `repo` для `applies`; SQLite-чтение пренебрежимо против сетевого PATCH;
  для не-self/выключенного флага — ранний ALLOW без лукапа окна.
- **−** Если фактический актор флаппа — **внешняя** Plane-automation под другим токеном (вне кода
  орка), code-фикс не закроет G1 полностью. Митигейшн: гард — буфер на стороне орка; локализация
  актора (FR-1) и итог документируются (BR-7) — этот ADR фиксирует гипотезу «под бот-токеном орка».
- **−** Перенос арм-блока меняет порядок внутри маркированного блока ORCH-021/066. Митигейшн:
  инварианты обоих ADR проверены сохранёнными (D3); анти-регресс — TC-11 (рабочий цикл) + структурные
  тесты.
- **Откат:** `ORCH_DEPLOY_STATUS_GUARD_ENABLED=false` → сеттеры терминал-слепы, поведение 1:1
  прежнее (D2 шаг 1). Полный откат — revert ветки (перенос арм-блока + leaf + config + сеттер-врезки).

## Ссылки

- BRD: `docs/work-items/ORCH-094/01-brd.md`
- TRZ: `docs/work-items/ORCH-094/02-trz.md`
- Acceptance: `docs/work-items/ORCH-094/03-acceptance-criteria.md`
- Tech-risks: `docs/work-items/ORCH-094/10-tech-risks.md`
- Сквозной ADR: `docs/architecture/adr/adr-0028-terminal-window-aware-deploy-status-guard.md`
- Сверено по коду: `src/stage_engine.py` (369/404/431/1218/1316/~1698-1729),
  `src/plane_sync.py` (913/954/964/974, `_set_issue_state_direct`), `src/post_deploy.py`
  (`arm_monitor`/`has_marker`/`ARMED`/`DONE`/`state_dir`), `src/reconciler.py` (F-1/F-2),
  `src/config.py` (флаги ORCH-088/021/036), `src/db.py` (`get_task_by_plane_id`).
- Маркеры (прочитаны, не сломаны): ORCH-021 (`adr-0010` / `06-adr/ADR-001`), ORCH-066
  (`06-adr/ADR-001-plane-status-model`), ORCH-086/068 (терминал-скип), ORCH-088 (freeze),
  ORCH-090 (cancelled-терминал).
