# ADR-001: Источник Plane-статуса для live-карточки и кликабельный номер задачи

- **Статус:** Proposed
- **Дата:** 2026-06-08
- **Задача:** ORCH-067
- **Слой:** B (индикация), НЕ слой A (машина стадий) — см. CLAUDE.md / ORCH-066
- **Связи:** ORCH-066 (статусная модель Plane, `_PLANE_NAME_TO_KEY` / `_STAGE_TO_STATE_KEY`),
  ORCH-042 (live-tracker, режимы `edit`/`bump`), ORCH-017 (`_build_plane_issue_link`,
  `plane_web_url`/`plane_workspace_slug`, loopback-guard), ORCH-059 (Confirm Deploy),
  ORCH-060 (`fetch_issue_state`), ORCH-010 (`get_project_states` per-project + кэш),
  adr-0001 (реестр проектов), adr-0010 (post-deploy monitor).

## Контекст

ТЗ ORCH-067 (`02-trz.md`) фиксирует объём изменений; данный ADR закрывает развилки,
явно отданные архитектору метками `[ARCH]`:

1. **Источник «истинного» Plane-статуса для веток, не выводимых из `tasks.stage`**
   (Needs Input, Blocked, Rejected, Cancelled, Deploying, Monitoring after Deploy),
   при **запрете менять схему БД** (нельзя добавить колонку-флаг). TZ §2.2 предлагает
   два варианта: (а) best-effort чтение живого Plane-статуса с fail-safe;
   (б) только stage-выводимые статусы.
2. **Способ доступа к `plane_issue_id`/`project_id`** в каждой точке `send_telegram`,
   где есть только `work_item_id` (требование 4), оставаясь fail-safe.
3. Смена дефолта `tracker_mode` (`edit` → `bump`) для общего инстанса.

### Ключевая находка анализа (определяет развилку 1)

Когда аналитик задаёт вопросы, `stage_engine.start_pipeline` при наличии
`01-questions.md` вызывает `set_issue_needs_input(work_item_id)` (Plane → Needs Input),
но **DB-стадия остаётся `analysis`**, а BRD-часы (`brd_review_started_at`) **не
запускаются** (они стартуют позже, в `notify_approve_requested`, когда BRD готов).
Следовательно состояния **`Analysis` (аналитик работает)** и **`❓ Needs Input`
(аналитик ждёт ответа)** **неразличимы** по offline-данным БД (`stage` + brd-clock).
Единственный авторитетный источник этого различия — **живой Plane-статус**, который
оркестратор сам выставил через `set_issue_needs_input`.

То же касается `Deploying` / `Monitoring after Deploy`: на стадии `deploy`/`done`
конкретная фаза self-deploy видна только в Plane (ORCH-059/ORCH-066), не в `tasks.stage`.

Вывод: чисто-offline вариант (б) **не покрывает обязательный по DoD `❓ Needs Input`**
(AC-8). Нужен гибрид.

## Решение

### Р-1. Гибрид: offline-first ядро + best-effort live-overlay

Статус карточки строится в два слоя; **offline-ядро авторитетно и всегда работает без
сети**, live-overlay лишь дорисовывает ветки, неотличимые offline.

**Слой 1 — чистая offline-функция `plane_status_label(task_row) -> str`** в
`src/notifications.py`. Детерминированная, **никогда не бросает**, **никогда не ходит в
сеть**. Маппинг (имена статусов — финальные из ORCH-066 `_PLANE_NAME_TO_KEY`):

| Источник (DB) | Метка карточки |
|---|---|
| `stage == "created"` | `To Analyse` |
| `stage == "analysis"`, brd-clock не запущен | `Analysis` |
| `stage == "analysis"`, `brd_review_started_at` есть, `brd_review_ended_at` пуст | `⏸️ In Review — ожидание согласования BRD` |
| `stage == "architecture"` | `Architecture` |
| `stage == "development"` | `Development` |
| `stage == "review"` | `Code-Review` |
| `stage == "testing"` | `Testing` |
| `stage == "deploy"` | `⏸️ Awaiting Deploy — ожидание Confirm Deploy` |
| `stage == "done"` | `Done` |
| неизвестный/битый `stage` | дефолт: `html`-безопасная строка по `stage` (или `To Analyse`) |

Этого слоя достаточно для **`⏸️ In Review`** и **`⏸️ Awaiting Deploy`** — оба
обязательны по DoD и **работают без сети** (AC-7, AC-8). `In Review` выводится
исключительно из brd-clock.

**Слой 2 — best-effort live-overlay** `_live_plane_branch_override(repo, plane_issue_id,
base_label) -> str` для веток, неразличимых offline: **Needs Input, Blocked, Rejected,
Cancelled, Deploying, Monitoring after Deploy**. Алгоритм:

1. Резолв `project_id` по `repo` (`get_project_by_repo(repo).plane_project_id`).
2. `live_uuid = fetch_issue_state(plane_issue_id, project_id)` (ORCH-060) — **с коротким
   таймаутом** (см. Р-4), не дефолтным 10s.
3. Сопоставление `live_uuid` с **конкретными** UUID веток из
   `get_project_states(project_id)` (кэш ORCH-010): `needs_input`, `blocked`,
   `cancelled`, `rejected`, `deploying`, `monitoring`.
4. Override применяется **только** если `live_uuid` совпал с одним из этих ключей.
   Иначе возвращается `base_label` (offline-метка).

**Прецеденс (порядок приоритета):**
1. Если offline-ядро дало **`⏸️ In Review`** (brd-clock) — overlay **не вызывается**:
   brd-clock авторитетнее возможно-устаревшего Plane-чтения для In Review.
2. Иначе `base_label` = offline-метка, затем применяется overlay (если включён и удался).

**Анти-false-positive на enduro (важно):** на enduro-trails ключи `deploying`/
`monitoring` алиасят UUID `in_progress`/`done` (`_STATE_ALIAS_FALLBACK`), поэтому прямое
сравнение UUID дало бы ложный `Deploying` для любой `in_progress`-задачи. Поэтому для
`deploying`/`monitoring` override применяется **только если** их UUID в
`get_project_states` **отличается** от UUID базового ключа (т.е. проект реально завёл
отдельный статус — это ORCH, не enduro). Ключи `needs_input/blocked/cancelled/rejected`
имеют отдельные UUID и на enduro, и на ORCH (`_DEFAULT_STATES`), поэтому различимы всегда.

### Р-2. Fail-safe и невлияние на конвейер (overlay)

- `_live_plane_branch_override` обёрнут в `try/except` и **никогда не бросает**; любая
  ошибка/таймаут/нет сети/нет данных → возвращается `base_label`. Это удовлетворяет
  «без сети не падать» и AC-9 (рендер карточки никогда не падает).
- Нет `plane_issue_id` / нет `project_id` / нет креды → overlay не вызывается, метка =
  offline-ядро.
- **Kill-switch:** новый флаг конфигурации `tracker_live_status: bool = True`
  (env `ORCH_TRACKER_LIVE_STATUS`). При `False` overlay полностью отключён (никаких
  сетевых чтений в рендере) — карточка деградирует на offline-ядро. Это аварийный
  тумблер и страховка от регресса для не-ORCH проектов. **Дефолт `True`**, иначе
  обязательный по DoD `Needs Input` не отобразится из коробки.

### Р-3. Кэш live-статуса (защита hot-path)

`render_task_tracker` вызывается на КАЖДОМ обновлении трекера (старт/финиш агента,
переход стадии), а в режиме `bump` — с delete+send каждый раз. Чтобы серия быстрых
перерисовок не била по Plane:

- Добавить **TTL-кэш per-issue** для `live_uuid` (ключ — `plane_issue_id`, TTL
  `tracker_live_status_ttl_s: int = 60`). По образцу `_STATES_CACHE` в `plane_sync.py`.
- На промахе кэша — один `fetch_issue_state` с коротким таймаутом; результат кладётся в
  кэш. На любой ошибке кэш не портится, возвращается offline-метка.

Это ограничивает сетевую нагрузку overlay ~одним GET в `TTL` на задачу.

### Р-4. Короткий таймаут live-чтения в рендере

`fetch_issue_state` (ORCH-060) хардкодит `timeout=10`. Для пути рендера это слишком
долго (рендер синхронный, в линии переходов общего конвейера). Решение: добавить в
`fetch_issue_state` **необязательный параметр `timeout`** (дефолт прежний `10` —
обратная совместимость для reconciler), а overlay вызывает его с
`settings.tracker_live_status_timeout_s` (дефолт **3** с). Поведение/сигнатуры
существующих вызовов не меняются.

### Р-5. Единый хелпер кликабельного номера `plane_issue_link`

Добавить в `src/notifications.py`:

```python
def plane_issue_link(work_item_id, plane_issue_id=None, project_id=None, repo=None) -> str:
    """HTML с кликабельным номером (<a href=...>ORCH-NNN</a>) или html.escape(work_item_id).
    Никогда не падает."""
```

- Переиспользовать логику и guard'ы `_build_plane_issue_link` (ORCH-017), **разнеся**
  «текст ссылки = номер задачи» и «текст ссылки = `✅ Задача в Plane`», чтобы не
  дублировать резолв проекта и loopback-guard. Рекомендуется выделить приватный
  `_plane_issue_url(repo, plane_issue_id, project_id) -> str | None` (сборка URL +
  loopback/workspace/project guard), который зовут оба: `plane_issue_link` (текст =
  номер) и `_build_plane_issue_link` (текст = «✅ Задача в Plane»).
- База URL: `plane_web_url` → fallback `plane_api_url`; loopback → «нет web URL»
  (`_is_loopback_base`).
- `project_id`: явный аргумент → иначе резолв по `repo`.
- URL: `{web_base}/{workspace}/projects/{project_id}/issues/{plane_issue_id}/`.
- Текст = `html.escape(work_item_id)`; `href` = `html.escape(url, quote=True)`.
- **Fail-safe:** не хватает любого из (web_base/не-loopback, workspace, project_id,
  plane_issue_id) → вернуть `html.escape(work_item_id)` (номер без ссылки). Никогда не
  бросает (AC-11, AC-12).

### Р-6. Доступ к `plane_issue_id`/`project_id` в точках уведомлений (требование 4)

В большинстве точек `send_telegram` доступен только `work_item_id`. Решение —
тонкая fail-safe обёртка по образцу `_get_task_link_fields`:

```python
def link_for(work_item_id, task_id=None) -> str:
    """По work_item_id (или task_id) достать repo+plane_issue_id из БД и вернуть
    plane_issue_link(...). На любой нехватке данных -> html.escape(work_item_id)."""
```

- Если у точки есть `task_id` — читать `(repo, plane_issue_id)` напрямую из `tasks` по
  `id`. Если только `work_item_id` — `SELECT repo, plane_issue_id FROM tasks WHERE
  work_item_id=? ORDER BY id DESC LIMIT 1` (как в `_resolve_project_id`).
- Везде, где данных нет — деградация на `html.escape(work_item_id)`, без падения.
- Применить во всех точках из TZ §3.3 (`notify_approve_requested`, `notify_error`,
  `stage_engine`, `launcher`, `merge_gate`, `job_reaper`, `security_gate`, `reconciler`,
  `main`) — **только там, где упоминается номер задачи**.

### Р-7. `tracker_mode` дефолт → `bump`

`src/config.py`: `tracker_mode: str = "edit"` → `"bump"`. Инвариант «одна карточка на
задачу» сохранён в обоих режимах (код `update_task_tracker` не меняется по сути).
`edit` остаётся доступен через `ORCH_TRACKER_MODE=edit`. Транзиентный фейл `send` не
обнуляет `tracker_message_id` (инвариант уже в коде — сохранить).

### Р-8. Чего НЕ делаем (границы)

- НЕ менять схему БД, `STAGE_TRANSITIONS`, Quality Gates, транспорт
  `send_telegram`/`edit_telegram`/`delete_telegram`, `disable_notification`-семантику.
- НЕ менять поведение агентов/конвейера. Слой B (индикация) не управляет слоем A.
- НЕ добавлять блокирующих сетевых ожиданий в линию переходов сверх одного короткого
  best-effort GET с кэшем (Р-3/Р-4).
- НЕ создавать глобальный (сквозной) ADR: изменение локально для `notifications.py` +
  один config-дефолт, не вводит новую стадию/QG/компонент. Достаточно per-work-item ADR.

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

**Плюсы**
- Обязательные по DoD `⏸️ In Review`, `⏸️ Awaiting Deploy` работают **без сети**
  (детерминированно, тестируемо offline — AC-6/AC-7).
- `❓ Needs Input` (и Blocked/Rejected/Cancelled/Deploying/Monitoring) отражаются через
  авторитетный источник — живой Plane-статус, который иначе невосстановим из БД.
- Единый хелпер ссылки убирает дублирование резолва проекта/loopback-guard (ORCH-017).
- Kill-switch + кэш + короткий таймаут ограничивают риск для общего инстанса.

**Минусы / ограничения**
- Overlay добавляет ≤1 короткий GET (3 с таймаут) на задачу в `TTL=60s` в путь рендера.
  Митигировано кэшем, таймаутом и kill-switch.
- При недоступном Plane ветки `Needs Input`/`Blocked`/… деградируют на offline-метку
  (`Analysis`/stage). Это осознанный, безопасный компромисс (рендер важнее точности
  ветки; конвейер не блокируется).
- На частично сконфигурированном проекте без отдельных статусов `Deploying`/`Monitoring`
  эти ветки не показываются (alias-guard) — корректная деградация, не баг.

**Риски** — см. `10-tech-risks.md`.

## Альтернативы (отклонены)

- **Только offline (вариант б TZ).** Отклонён: не отличает `Needs Input` от `Analysis`
  → не покрывает обязательный AC-8.
- **Чтение `01-questions.md` из worktree как offline-сигнал Needs Input.** Отклонён:
  хрупко (резолв пути worktree из `notifications.py`, файл может пережить ответ,
  гонки) — менее надёжно, чем авторитетный Plane-статус.
- **Добавить DB-колонку-флаг для ветки.** Запрещено TZ (без изменения схемы).
- **Асинхронный фон/демон для подтяжки статуса.** Избыточно для слоя индикации; кэш +
  короткий таймаут дешевле и проще, без нового компонента.
