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

# ADR-001: Карточка трекера — полнота карты статусов, отражение откатов, суммирование метрик по попыткам

Work Item: **ORCH-091** — три верифицированных дефекта live-карточки (`src/notifications.py`)
Стадия: **architecture**
Сквозная регистрация: **N/A, локальное решение задачи** (затронут ровно один модуль
индикативного слоя `src/notifications.py`; `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` /
схема БД / транспорт нотификаций — не трогаются; новый компонент/стадия/гейт не вводятся).

## Статус
Accepted

## Контекст

Live Telegram-карточка (ORCH-067/087, единственная карточка на задачу,
`render_task_tracker` / `plane_status_label`) — основной канал наблюдения за конвейером.
BRD/ТЗ объединяют три дефекта, сверенные по коду и БД прода (09.06):

- **Деф.1 — застрявший заголовок «To Analyse».** `_STAGE_STATUS_LABEL`
  (`src/notifications.py:940`) содержит 8 ключей (`created/analysis/architecture/development/
  review/testing/deploy/done`), а `tasks.stage` принимает ключи `STAGE_TRANSITIONS`
  (`src/stages.py:12`) — среди них **`deploy-staging`** (не покрыт) и **`cancelled`** (ORCH-090,
  не покрыт). `plane_status_label` (`:1009`) делает `.get(stage, _DEFAULT_STATUS_LABEL)` →
  непокрытая стадия отдаёт дефолт **«To Analyse»** (`:950`). Из 10 реальных стадий не покрыты
  две; дефолт-«To Analyse» — ещё и мина: любая новая стадия даст ложный «первый статус».
- **Деф.2 — ложная картина при откате.** Цикл рендера (`:474–505`) выводит `✅`-строку для
  каждой стадии `_TRACKER_STAGES`, у чьего агента есть завершённый прогон (`last_done`), **без
  учёта позиции стадии относительно текущей**. После отката (`deploy-staging → development`,
  ORCH-43; `review → development`, REQUEST_CHANGES) карточка показывает «✅ Внедрение … +
  🔄 Разработка» — абсурд.
- **Деф.3 — занижение метрик строки стадии.** `_stage_line` берёт `run = last_done.get(agent)`
  (`:475`) — ПОСЛЕДНИЙ прогон, теряя предыдущие попытки. Верифицировано на ORCH-069 (task 54):
  developer 3 прогона Σ $3.98, карточка показывала ~$0.00. Блок тоталов задачи (`:388–404`) уже
  суммирует все прогоны — заниженной остаётся **строка стадии**.

Ключевая структурная сложность (флаг ТЗ §FR-4): `_TRACKER_STAGES` — 6 строк; стадии
`deploy-staging` и `deploy` **схлопнуты** в одну строку «Внедрение» (`stage_key="deploy"`,
агент `deployer`). `_STAGE_ACTIVE_AGENT` тоже не содержит `deploy-staging`. Любое решение по
порядку/позиции обязано не сломать этот сложившийся рендер строки «Внедрение».

## Решение

### Сводка

Три аддитивные правки в `src/notifications.py`, минимизирующие регресс-поверхность:
1. **Полнота карты** — расширить `_STAGE_STATUS_LABEL` недостающими ключами
   (`deploy-staging`, `cancelled`); заменить runtime-фолбэк с «To Analyse» на **нейтральный**
   (капитализированное имя стадии). Полнота гарантируется **тестом**, итерирующим
   `STAGE_TRANSITIONS.keys()` (единый источник истины), а не дублирующим списком.
2. **Отражение откатов** — ввести позицию стадии в конвейере из **порядка `STAGE_TRANSITIONS`**
   и гасить `✅`-строку для стадий ПОЗЖЕ текущей позиции. Нормализация `deploy-staging → deploy`
   применяется **только** к вычислению текущей позиции (для гейта подавления), логика
   `is_active_stage` — **без изменений** (нулевой регресс активного рендера).
3. **Суммирование метрик** — `_stage_line` агрегирует ВСЕ `agent_runs` агента стадии
   (теми же per-run-аккумуляторами, что и блок тоталов) → строгая сходимость с `SUM(agent_runs)`.

Все функции остаются **stateless / never-raise**; любая ошибка деградирует к безопасному
выводу (старое поведение).

### D1 — Полнота `_STAGE_STATUS_LABEL` + нейтральный фолбэк (Деф.1 / FR-1,2,3 / AC-1,2,3)

- Расширить `_STAGE_STATUS_LABEL`, добавив **все** недостающие ключи `STAGE_TRANSITIONS`:
  - `"deploy-staging": "Deploying (staging)"` — осмысленный staging-лейбл, согласованный с
    моделью статусов ORCH-066/059: **plain-стиль** активной стадии (как `Analysis`/`Testing`,
    без `⏸️`-маркера паузы), **отличен** от «To Analyse» и от лейбла `deploy`
    («⏸️ Awaiting Deploy — ожидание Confirm Deploy»). Суффикс «(staging)» снимает коллизию с
    prod-overlay «Deploying» (`_LIVE_BRANCH_LABELS['deploying']`). (FR-2 / AC-2.)
  - `"cancelled": "Cancelled"` — offline-база для системного терминала ORCH-090. Совпадает с
    overlay-лейблом `_LIVE_BRANCH_LABELS['cancelled']` ("Cancelled") → нет конфликта precedence
    в `_card_status_label`; offline-путь больше не отдаёт «To Analyse» для отменённой задачи.
- **Runtime-фолбэк** в `plane_status_label`: вместо `_STAGE_STATUS_LABEL.get(stage,
  _DEFAULT_STATUS_LABEL)` использовать **нейтральный** лейбл для отсутствующего ключа —
  капитализированное имя стадии (напр. `stage.replace("-", " ").title()` → «Deploy Staging»),
  с финальным безопасным дефолтом при пустом/битом входе. `created` сохраняет осмысленный
  «To Analyse» как реальный первый статус (он **остаётся явным ключом** в карте). (FR-3 / AC-3.)
- `_DEFAULT_STATUS_LABEL` сохраняется как имя для `created` и для безопасной деградации на
  истинно-битом входе (`None`/нет ключа `stage`); он **перестаёт** быть фолбэком для «известная
  стадия, но нет лейбла» — этот путь теперь нейтрально-капитализированный.
- Спецветка `analysis` + открытый brd-clock → `_IN_REVIEW_LABEL` — **без изменений** (NFR-2).
- **Программная полнота (NFR-3)** обеспечивается тестом, который итерирует
  `from src.stages import STAGE_TRANSITIONS` и для каждого ключа (кроме `created`) утверждает
  непустой лейбл `≠ _DEFAULT_STATUS_LABEL`. Новая стадия без курируемого лейбла → красный тест.
  **Запрещено** в самом модуле автогенерировать лейблы из имён стадий (теряется человеческая
  осмысленность) — карта остаётся курируемой, тест лишь гарантирует её покрытие.

### D2 — Отражение откатов: позиция из `STAGE_TRANSITIONS` (Деф.2 / FR-4 / AC-4)

- Ввести в `src/notifications.py` (НЕ в `src/stages.py` — он read-only по ТЗ) лёгкий
  индекс-хелпер от **единого источника порядка** `STAGE_TRANSITIONS`:
  ```python
  from .stages import STAGE_TRANSITIONS
  _PIPELINE_ORDER = list(STAGE_TRANSITIONS.keys())  # created..done, cancelled
  def _pipeline_pos(stage):            # never-raise
      try:
          return _PIPELINE_ORDER.index(stage)
      except (ValueError, TypeError):
          return len(_PIPELINE_ORDER)  # unknown -> «далёкое будущее»
  ```
- **Нормализация staging→deploy ТОЛЬКО для текущей позиции:**
  `effective_stage = "deploy" if stage == "deploy-staging" else stage`;
  `current_pos = _pipeline_pos(effective_stage)`. Это отражает, что строка «Внедрение»
  представляет фазу deployer'а (staging+prod) как одну — иначе при `stage='deploy-staging'`
  строка «Внедрение» (`stage_key="deploy"`, pos 7) была бы ошибочно подавлена (6 < 7).
- **Гейт подавления:** ветка `elif run is not None` (рендер `✅ <стадия>`) срабатывает **только
  если** `current_pos >= _pipeline_pos(stage_key)`. Иначе (прогон есть, но стадия ПОЗЖЕ
  текущей — откат) строка не выводится. Стадии ДО/НА текущей позиции сохраняют `✅` (фактически
  пройденные).
- **`is_active_stage` — без изменений** (использует «сырой» `stage`, `_STAGE_ACTIVE_AGENT`,
  `has_inflight`). Это даёт нулевой регресс активного/«just-finished snapshot» рендера (NFR-2):
  при `stage='deploy-staging'` строка «Внедрение» ведёт себя как сегодня (✅ при завершённом
  staging-прогоне, иначе ничего) — нормализация затрагивает лишь гейт подавления, не активность.
- Источник позиции — **порядок `STAGE_TRANSITIONS`**, а не индекс в `_TRACKER_STAGES` (NFR-3,
  FR-4): добавление/перестановка стадий в движке автоматически корректирует подавление.
- **Условие 🔄 после отката (AC-4):** строка «Разработка» рисуется активной (`🔄`) существующей
  логикой `is_active_stage`, которой нужен `has_inflight or run is None`. Реальное
  пост-откатное состояние — reviewer/merge-gate ставит developer-job → launcher создаёт строку
  `agent_runs` c `finished_at IS NULL` (in-flight) → `🔄`. Фикстура AC-4 обязана содержать
  этот in-flight developer-прогон (так выглядит прод после отката). Наш фикс ортогонально
  снимает ложные `✅` со стадий review/testing/Внедрение.

### D3 — Суммирование метрик строки стадии (Деф.3 / FR-5 / AC-5)

- `_stage_line` принимает **список прогонов** агента стадии (готовый
  `agent_runs_by_agent.get(agent, [])`) вместо одного `run` и агрегирует **теми же
  per-run-формулами, что блок тоталов задачи** (`:388–404`):
  - 💰 `cost = Σ float(cost_usd or 0)`;
  - 🔢 `in = Σ _input_total(usage)` (= Σ(input+cache_read+cache_creation)),
    `out = Σ int(output_tokens or 0)`; формат `<in>↓/<out>↑` сохранён;
  - ⏱ `dur = Σ _duration_seconds(started_at, finished_at)` (None-прогоны пропускаются, как в
    тоталах).
- **Инвариант сходимости:** блок тоталов и строки стадий теперь аккумулируют **по одному и тому
  же множеству строк `agent_runs`** и **одними формулами**; каждый агент привязан ровно к одной
  строке `_TRACKER_STAGES` (analyst/architect/developer/reviewer/tester/deployer). Поэтому
  Σ(показанных+подавленных строк стадий) ≡ тоталы задачи ≡ `SUM(agent_runs)` по `task_id`
  (по стоимости/токенам/времени). Подавлённые откатом строки (D2) не рисуются, но их прогоны
  **по-прежнему** входят в тоталы — это и есть намеренная семантика отката, инвариант AC-5 не
  нарушается (тоталы считают всё; строка стадии — Σ своих прогонов).
- **Модель/эффорт/«попытка N» (ORCH-087, FR-5):** агрегируются метрики, но модель/эффорт
  берутся из **последнего** прогона агента (`agent_runs` упорядочены `id ASC` → последний
  элемент списка) через существующие `short_model_name` / `_run_effort`; счётчик попыток для
  активной строки (`len(agent_runs)`) — без изменений. Формат строки байт-в-байт сохранён (NFR-2).

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

- **Автогенерация лейблов из имён стадий (полностью программная карта)** — отвергнуто: теряется
  человеческая осмысленность («deploy-staging» → не лучше «Deploying (staging)»); курируемая
  карта + тест полноты дают и читаемость, и анти-рассинхрон.
- **Нормализация `deploy-staging→deploy` во ВСЁМ цикле (включая `is_active_stage`)** —
  отвергнуто как первичное решение: меняет активный рендер строки «Внедрение» на стадии
  `deploy-staging` (риск регресса существующих тестов, NFR-2/AC-6). Нормализация ограничена
  гейтом подавления — минимальная поверхность.
- **Позиция стадии из индекса `_TRACKER_STAGES`** — отвергнуто: `_TRACKER_STAGES` не содержит
  `deploy-staging`/`cancelled` и не является источником истины о порядке конвейера (нарушает
  NFR-3). Источник — `STAGE_TRANSITIONS`.
- **Изменение `_TRACKER_STAGES`/`_STAGE_ACTIVE_AGENT` (добавить deploy-staging-строку)** —
  отвергнуто: вне объёма BRD (формат строк неизменен, NFR-2), расширяет регресс-поверхность.

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

- **+** Заголовок честен на всех стадиях (вкл. `deploy-staging`, `cancelled`); будущая стадия
  не даёт ложный «To Analyse» (нейтральный фолбэк + тест полноты).
- **+** Карточка не «лжёт» после отката: `✅` снимается со стадий ПОЗЖЕ текущей позиции.
- **+** Метрики строки стадии = Σ всех попыток; строгая сходимость с `SUM(agent_runs)`.
- **+** Источник порядка/полноты — `STAGE_TRANSITIONS` (программно), анти-рассинхрон на будущее.
- **−** Новая read-only связь `notifications.py → stages.STAGE_TRANSITIONS` (порядок+ключи).
  Митигейшн: импорт ключей, `stages.py` не изменяется (разрешено ТЗ §2); `_pipeline_pos`
  never-raise (unknown → «далёкое будущее» = старое поведение, ✅ не пере-подавляется).
- **−** При `stage='deploy-staging'` строка «Внедрение» может показать `✅` по завершённому
  staging-прогону (до prod-деплоя). Это **сохранённое** поведение (NFR-2), не регресс и не
  дефект по BRD; нормализация затрагивает только подавление, не активность.
- **Откат:** изменение docs/code-only в одном модуле + тесты → `git revert` PR. Kill-switch не
  требуется (нет нового поведения конвейера; рендер never-raise деградирует безопасно).

## Ссылки
- BRD: `docs/work-items/ORCH-091/01-brd.md`
- TRZ: `docs/work-items/ORCH-091/02-trz.md`
- Acceptance: `docs/work-items/ORCH-091/03-acceptance-criteria.md`
- Tech-risks: `docs/work-items/ORCH-091/10-tech-risks.md`
- Сверено по коду: `src/notifications.py` (`_STAGE_STATUS_LABEL:940`, `_DEFAULT_STATUS_LABEL:950`,
  `plane_status_label:990`, `render_task_tracker:333`, `_stage_line:445`, `_TRACKER_STAGES:233`,
  `_STAGE_ACTIVE_AGENT:248`, totals `:388-404`), `src/stages.py::STAGE_TRANSITIONS:12`,
  `src/usage.py::_input_total:348`.
- Инварианты, которые НЕЛЬЗЯ ломать (прочитаны перед правкой): ORCH-067/ORCH-087
  (`docs/work-items/ORCH-067|ORCH-087/06-adr/`) — single-card, never-raise, разделение
  offline-ядра и live-overlay; ORCH-090 (`adr-0026`) — терминал `cancelled`.
