# ADR-001: Зачистка осиротевших трекер-карточек (bump + полный учёт message_id), эффорт в строке стадии, честное итоговое время

## Статус
Accepted

## Контекст

Каждая задача имеет ОДНУ live-карточку в Telegram (`update_task_tracker`, инвариант
«одна карточка на задачу»). Дефолтный режим — `bump` (ORCH-067/042): на каждом
обновлении старая карточка удаляется и новая шлётся вниз чата (фича-просьба Славы —
«карточка всегда внизу»). Указатель `tasks.tracker_message_id` — **скаляр**, хранит
ТОЛЬКО последний `message_id`.

**Симптом (скриншот Славы, 08.06, ORCH-082):** в чате висела карточка с заголовком
`📍 To Analyse`, хотя задача прошла весь конвейер до стадии `deploy`; статусы
deploy-цикла не отражены. Карточка — **осиротевшая** старая (`msg 18204`),
застрявшая на первом рендере (`To Analyse` = `_DEFAULT_STATUS_LABEL`). Проверено
(`deleteMessage → ok:true` и для 18204, и для 18227): бот ИМЕЕТ право удалять — дело
не в правах, а в **потере ссылки** на старые `message_id`.

BRD требует (BR-G0): сначала расследование → ADR, потом фикс. Ниже — ответы на все
4 вопроса §4 BRD, рекомендация и принятые архитектурные решения.

---

## G0 — Ответы на вопросы расследования (BR-G0, AC-0.1)

### Вопрос 1 — Сколько РЕАЛЬНО карточек одной задачи висело

По логам/скриншоту ORCH-082 подтверждено **минимум 2 живых сообщения** одной задачи
(`18204` — осиротевшая «замёрзшая» на `To Analyse`; `18227` — актуальная). Скалярный
указатель структурно допускает **N>1** сирот: каждый рассинхрон (см. вопрос 2) теряет
ровно одну ссылку, а сиротство накопительно — за прогон из ~8 переходов в худшем
случае осиротеть может до N−1 карточек. Точное число для конкретного прогона
непредсказуемо именно потому, что учёта старых mid НЕТ — это и есть корень бага.

### Вопрос 2 — В какие МОМЕНТЫ `tracker_message_id` рассинхронизируется

Текущий код (`update_task_tracker`, ветка `mode == "bump"`):
```python
if mid is not None:
    delete_telegram(mid)            # best-effort, результат НЕ гейтит send (BR-6)
new_mid = send_telegram(text, disable_notification=True)
if new_mid is not None:
    set_tracker_message_id(task_id, new_mid)   # перепонт ТОЛЬКО на новый mid
```

| Сценарий | Механика | Рождает сироту? |
|----------|----------|-----------------|
| (a) `send` → `None` (нет креды / transient) | `new_mid is None` → указатель НЕ перезаписан; но `delete(old)` уже выполнен best-effort. Старая удалена (или осталась, если delete тоже упал — см. e). | Сам по себе — нет; защита BR-6 корректна. |
| (b) рестарт орка между `delete` и `send` | `delete(old)` прошёл, процесс упал до `send` → при перезапуске рисуется новая, старая уже удалена. | Обычно нет; но если `delete` вернул False до падения — old жив, ссылка на него только в скаляре, который не менялся → следующий bump его подчистит. |
| (c) пересоздание карточки во время CLI-фикса / ручных операций | Ручной `sendMessage` или внешняя правка вне `update_task_tracker` создаёт mid, которого нет в учёте. | Да — учёт о нём не знает. |
| (d) **гонка** двух `update_task_tracker` подряд (быстрые стадии) | Оба читают один `mid`, оба `delete` его (один `ok`, второй `already gone`→True), оба `send` → **две** новых карточки; указатель садится на одну → вторая осиротела. | **Да** — частый на быстрых стадиях. |
| (e) **`delete` упал (transient/>48ч), но `send` прошёл** | `delete(old)` → False (old жив), `send` → new, указатель `=new` → ссылка на old **навсегда потеряна**. | **Да — доминирующий генератор сирот.** |

**Вывод:** доминируют (d) гонка и (e) delete-fail+send-ok. Общий первопричинный
дефект — **скалярный учёт**: система знает лишь о последнем `message_id`, поэтому при
любой потере ссылки старая карточка осиротевает безвозвратно.

### Вопрос 3 — Почему ИМЕННО заголовок застывает на `To Analyse`

Это **старый рендер**, а НЕ баг план-лейбла. Код-аудит подтверждает:
`render_task_tracker` → `_card_status_label` → `plane_status_label` детерминированно
выводит заголовок из `tasks.stage` (`_STAGE_STATUS_LABEL`), и на `deploy` корректно
даёт `⏸️ Awaiting Deploy`. Осиротевшая карточка `18204` была отрисована ОДИН раз на
самой ранней стадии (`stage` ещё `created`/`analysis` → `To Analyse` =
`_DEFAULT_STATUS_LABEL`) и больше не редактировалась/не удалялась (ссылка потеряна).
Рендер исправен; «замёрзший» заголовок — следствие сиротства (G1), а не G2.

**Таблица воспроизведения «стадия → (заголовок в Telegram) vs (stage в БД)»**
(аналитическая, выведена из кода `plane_status_label`/`_STAGE_STATUS_LABEL`; подлежит
подтверждению живым staging-прогоном TC-18 на 8501, AC-0.2):

| `tasks.stage` (БД) | Заголовок актуальной карточки (ожидаемо) | Заголовок ОСИРОТЕВШЕЙ (факт ORCH-082) |
|--------------------|------------------------------------------|----------------------------------------|
| created | `📍 To Analyse` | `📍 To Analyse` |
| analysis | `📍 Analysis` (или `⏸️ In Review` при открытом brd-clock) | `📍 To Analyse` (замёрзла) |
| architecture | `📍 Architecture` | `📍 To Analyse` |
| development | `📍 Development` | `📍 To Analyse` |
| review | `📍 Code-Review` | `📍 To Analyse` |
| testing | `📍 Testing` | `📍 To Analyse` |
| deploy | `📍 ⏸️ Awaiting Deploy — ожидание Confirm Deploy` (+overlay `Deploying`/`Confirm Deploy`/`Monitoring`) | `📍 To Analyse` |
| done | `🎉 … ГОТОВО` + `📍 Done` | `📍 To Analyse` |

Правый столбец — наглядное доказательство: одна карточка отстаёт на `stage` в БД
ровно потому, что потеряла ссылку и больше не обновляется.

### Вопрос 4 — `bump` vs `edit`: что надёжнее против сирот

| Критерий | `edit` (правка in-place) | `bump` (delete+send вниз) |
|----------|--------------------------|----------------------------|
| Сироты by design | **Нет** (одно сообщение редактируется) | **Да** при рассинхроне (вопрос 2) |
| «Карточка всегда внизу» (фича-просьба ORCH-042) | Теряется (карточка тонет вверх чата) | **Сохраняется** |
| Реакция на потерю ссылки | EDIT_GONE → один новый mid, старый и так недоступен | старый mid терялся → сирота |
| Поведение при гонке (d) | оба правят один mid (idempotent) | два новых сообщения |

`edit` строго надёжнее против сирот, но **регрессирует явную фича-просьбу** Славы
(«карточка внизу», ради которой bump и сделан дефолтом в ORCH-067). `bump` плодит
сирот **только** из-за скалярного учёта — устранимого первопричинного дефекта, а не
неотъемлемого свойства режима.

**Рекомендация (обоснованная данными): сохранить `bump` дефолтом и устранить
первопричину — вести ПОЛНЫЙ учёт незакрытых `message_id` (вариант A из R-5).** Это
даёт и фичу «карточка внизу», и отсутствие сирот. Переход на `edit` (вариант B) был
бы откатом UX-решения ORCH-067 ради лечения симптома, а не причины. `edit` остаётся
доступен через `ORCH_TRACKER_MODE=edit` (kill-switch неизменен).

---

## Решение

### Р-1 (G1) — bump + полный учёт message_id через таблицу-леджер `tracker_messages`

Вводится **аддитивная таблица-леджер** всех незакрытых карточек задачи (вариант A1
из R-5; выбран над JSON-массивом A2 — см. «Альтернативы»):

```sql
CREATE TABLE IF NOT EXISTS tracker_messages (
    task_id     INTEGER NOT NULL,
    message_id  INTEGER NOT NULL,
    created_at  TEXT DEFAULT (datetime('now')),
    deleted_at  TEXT,                 -- NULL = карточка ещё жива (незакрыта)
    PRIMARY KEY (task_id, message_id)
);
CREATE INDEX IF NOT EXISTS idx_tracker_messages_open
    ON tracker_messages(task_id) WHERE deleted_at IS NULL;
```

Скаляр `tasks.tracker_message_id` **сохраняется** (обратная совместимость: остаётся
указателем на ТЕКУЩУЮ карточку для прочих читателей `get_tracker_message_id`).
Леджер — авторитетный источник для зачистки.

**Алгоритм `update_task_tracker`, ветка `bump` (соблюдает R-1…R-6):**
1. Прочитать ВСЕ незакрытые mid задачи: `SELECT message_id FROM tracker_messages
   WHERE task_id=? AND deleted_at IS NULL` (R-1).
2. Для каждого: `delete_telegram(mid)`:
   - `True` (удалено ИЛИ `_DELETE_GONE_MARKERS` «already gone», вкл. >48ч) →
     `UPDATE … SET deleted_at=datetime('now')` (исключить из учёта, R-2);
   - `False` (transient/сеть/5xx) → оставить незакрытой для повторной попытки на
     следующем bump (R-2).
3. `new_mid = send_telegram(text, disable_notification=True)` — РОВНО один send (R-4).
4. Если `new_mid is not None`: `INSERT INTO tracker_messages(task_id, message_id)`
   **и** `set_tracker_message_id(task_id, new_mid)`. Если `None` — НЕ трогать ни
   леджер, ни указатель (R-3, сохранена защита BR-6).

**Инвариант (R после фикса):** после любого `update_task_tracker` все ранее созданные
карточки задачи либо удалены, либо помечены `deleted_at`, либо остались незакрытыми
для повторной попытки — НИ ОДНА не теряется из учёта (в пределах 48ч-лимита Telegram).

**Совместимость / миграция:** на первой инициализации существующий
`tasks.tracker_message_id` НЕ переносится автоматически в леджер (одноразовый бэкфилл
не требуется — старые сироты всё равно за 48ч-окном). Новый поток ведёт леджер с
нуля; никаких изменений данных enduro-trails.

**Зачистка delete ДО send** (как в текущем коде): момент пустоты тих
(`disable_notification`), приемлем.

### Р-2 (G1, остаточный риск гонки) — самозалечивание, без блокировок

Гонка (d) двух одновременных `update_task_tracker` (вызываются из queue-worker,
reconciler, reaper) может на ОДИН цикл оставить лишнюю карточку: оба прочитали тот же
открытый набор, оба отправили новую. Обе новые попадают в леджер → **следующий** bump
их зачистит. Это строго лучше текущего ПОСТОЯННОГО сиротства и **самозалечивается** за
один переход. Кросс-процессную сериализацию (файловый лок/транзакция) НЕ вводим:
контракт компонента — best-effort, never-raise, карточка silent; цена лока не
оправдана. Остаточный риск задокументирован (AC-1.4, §Последствия).

### Р-3 (G2) — заголовок текущей стадии

Отдельного кода не требует: после Р-1 в чате остаётся ОДНА живая карточка, а
`render_task_tracker`/`plane_status_label` уже выводят заголовок из `tasks.stage`.
Закрепляется регресс-юнитом: `plane_status_label` перебирает все стадии
`created…done` и даёт корректный лейбл (TC-06, AC-2.2).

### Р-4 (G3) — deploy-цикл на карточке

- `_STAGE_STATUS_LABEL["deploy"] = "⏸️ Awaiting Deploy — ожидание Confirm Deploy"`
  (offline) — присутствует, покрывает AC-3.1.
- live-overlay `_live_plane_branch_override` рисует `Deploying` / `Monitoring after
  Deploy` через `_LIVE_BRANCH_LABELS` при наличии выделенного Plane-UUID — покрывает
  AC-3.2.
- **Добавить (полнота цикла):** ключ `"confirm_deploy": "⏳ Confirm Deploy —
  подтвердите прод-деплой"` в `_LIVE_BRANCH_LABELS` (логический ключ `confirm_deploy`
  уже существует в `plane_sync` с ORCH-059). Без base-alias (это реальный отдельный
  статус). Контракт never-raise и kill-switch `tracker_live_status` сохранены.
- `Done` рендерится из `stage == "done"` (AC-3.3) — без изменений.

### Р-5 (BR-EFF) — эффорт в строке стадии

- **Схема:** новая колонка `agent_runs.effort TEXT` через
  `_ensure_column(conn, "agent_runs", "effort", "TEXT")` рядом с `model` (аддитивно,
  идемпотентно).
- **Стамп в момент запуска** (`launcher._spawn`): сразу после строки
  `effort = resolve_agent_effort(agent, project_id)` (line 475) выполнить
  `UPDATE agent_runs SET effort=? WHERE id=run_id` со значением `effort or None`
  (РЕАЛЬНО ушедшее в `--effort`; пустое → `NULL` → суффикс опускается). Выбран
  follow-up `UPDATE` (а не расширение `INSERT` на line 449) — минимальный диф, без
  переноса резолва модели/эффорта выше по коду; значение точно соответствует флагу
  запуска. CLI не возвращает эффорт в result-JSON, поэтому стамп — единственный
  надёжный источник (BR §6).
- **Рендер** (`render_task_tracker._stage_line`): добавить `effort` в SELECT
  `agent_runs` и в строку стадии **единым форматом `· {model} · {effort}`**
  (напр. `✅ Разработка 12м · …↓/…↑ · $… · opus-4-8 · xhigh`). Пустой/неизвестный
  эффорт → суффикс эффорта опускается (как опускается модель при пустой
  `short_model_name`) — рендер не падает (AC-E.4). Допустим fallback на
  `resolve_agent_effort(run["agent"])` для исторических строк без колонки.
- **Ожидаемо** (ORCH-41/081): developer=`xhigh`; tester/deployer=`medium`;
  analyst/architect/reviewer=`high` (AC-E.3).

### Р-6 (BR-G5) — честное и сходимое итоговое время

Текущая строка `done` («магическое» раздутое число) заменяется на **три
независимых, явно подписанных метрики** — ни одна не выдаётся за сумму других
(удовлетворяет T-4 формулировкой «не показывать wall как сумму»):

```
⏱️ Агенты {agent_seconds} · твоё {review_capped} · общее с ожиданием {wall}
```

- **T-1 `agent_seconds`** = `Σ _duration_seconds(started, finished)` по `agent_runs`
  — **главная метрика**, остаётся точной (без регресса).
- **T-2 `review_capped`** — человеческое BRD-время, ограниченное разумным порогом
  `tracker_brd_review_cap_s` (новый config-флаг, env `ORCH_TRACKER_BRD_REVIEW_CAP_S`,
  **дефолт 7200с = 2ч**). При `review_seconds > cap` отображается capped-значение с
  маркером «~» (напр. `~2ч`), сигнализируя об отсечке аномального застоя/рассинхрона
  (кейс ORCH-087: brd_review болтался открытым из-за In Review→Backlog desync,
  показывал 392м). Выбран порог (а не «активные окна») — под-оконных данных у нас нет
  (только `brd_review_started_at`/`ended_at`); порог — допустимый T-2 вариант.
  Закрывает AC-5.1 (6ч-окно → не ~6ч).
- **T-3 `wall`** = `_duration_seconds(created_at, updated_at)` — подписан **«общее с
  ожиданием»**, НЕ выдаётся за рабочее время. Включает очередь/ожидание/застой.
- **T-4** соблюдён: метрики независимы и явно подписаны; wall НЕ представлен как
  `агенты + твоё` (несведение по незалогированным queue-паузам перестаёт «врать»).
- **T-5** `💰`-строка и агрегаты `total_in/out/cost` — без изменений.

### Р-7 (BR-G6) — свежий main / без эрозии reconciler

Подтверждено на стадии архитектуры: `git merge-base --is-ancestor origin/main HEAD`
→ true (origin/main содержит merge-коммит ORCH-086, #86); в `src/reconciler.py`
ветки присутствуют 43 маркера ORCH-086 (`skipped_terminal_total`, `state_uuid`,
terminal-skip). **Файлы ORCH-087 (`notifications.py`, `db.py`, `agents/launcher.py`,
`usage.py`, тесты) НЕ пересекаются с `reconciler.py`** → правки 86 не эродируются.
`CHANGELOG.md` правится под `.gitattributes merge=union`. Явная проверка на
merge-gate — AC-6.1/AC-6.2 (TC-19).

---

## Инварианты (не нарушаются)

- never-raise по всему пути нотификаций; карточка всегда silent (`disable_notification`).
- «одна карточка на задачу»; ≤1 `send` за вызов `update_task_tracker` (R-4).
- Ссылки ORCH-067 (`plane_issue_link`), `disable_web_page_preview` ORCH-080 — сохранены.
- `STAGE_TRANSITIONS` / реестр `QG_CHECKS` / стадии конвейера — **без изменений**.
- Миграции БД аддитивны и идемпотентны (`CREATE TABLE IF NOT EXISTS` / `_ensure_column`),
  restart-safe на общей прод-БД; данные enduro-trails не трогаются.

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

- **Вариант B (переход дефолта на `edit`)** — устраняет сирот by design, но
  регрессирует фича-просьбу «карточка внизу» (ORCH-042/067). Лечит симптом, а не
  причину. Отклонён; `edit` остаётся опцией через kill-switch.
- **Вариант A2 (JSON-массив `tasks.tracker_message_ids`)** — компактнее, но
  read-modify-write блоба сам подвержен lost-update при гонке (d) (два процесса
  перезапишут JSON друг друга — ровно тот класс багов, что чиним). Строка-на-mid в
  таблице с раздельными INSERT/UPDATE этого избегает и даёт `deleted_at` для ретрая
  transient-delete + наблюдаемость. Отклонён в пользу A1.
- **Файловый/транзакционный лок против гонки (d)** — избыточен для best-effort
  silent-карточки; леджер самозалечивается за один переход. Отклонён.

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

**Плюсы:**
- Уходит класс багов «замёрзшая сирота» — в чате ровно одна достоверная карточка.
- Сохранена фича «карточка всегда внизу» (bump-дефолт).
- Эффорт виден рядом с моделью; источник стампа надёжен (момент запуска).
- Итоговое время честно и подписано; «магическое» раздутое число устранено.
- Все изменения аддитивны/идемпотентны, kill-switch'и сохранены, машина стадий не тронута.

**Минусы / ограничения:**
- **Telegram-лимит 48ч:** сообщения старше 48ч удалить нельзя (`_DELETE_GONE_MARKERS`
  классифицирует это как «gone» → исключаются из учёта). Совсем старые сироты (до
  деплоя фикса) могут остаться навсегда — **known limitation** (AC-1.4).
- **Остаточная гонка (d):** одна лишняя карточка может прожить один переход до
  самозалечивания на следующем bump (см. Р-2).
- Новая таблица + колонка + один config-флаг — небольшой прирост схемы (оправдан).
- Порог `tracker_brd_review_cap_s` — эвристика: легитимный человеческий review длиннее
  2ч будет отображён как `~2ч`. Порог конфигурируем; компромисс «честность vs точность»
  в пользу неинтроду­цирования аномального застоя в «твоё время».
