# ТЗ — ORCH-087

Техническое задание для архитектора/разработчика. Конкретные изменения кода/БД с привязкой к BR (см. `01-brd.md`). Архитектурные РЕШЕНИЯ (выбор механизма зачистки сирот, выбор `bump`/`edit`, формула отсечки аномалий времени) принимает архитектор в ADR на основе G0 — здесь зафиксированы требования и точки врезки.

---

## 0. Задействованные модули `src/`

| Модуль | Роль в задаче |
|--------|---------------|
| `src/notifications.py` | `update_task_tracker` (bump/edit), `render_task_tracker`, `_stage_line`, итоговая строка времени, `plane_status_label`/`_card_status_label` (заголовок/deploy-цикл) |
| `src/db.py` | учёт `message_id` карточек задачи (BR-G1); колонка `agent_runs.effort` (BR-EFF); геттеры/сеттеры |
| `src/agents/launcher.py` | `_spawn`: стамп `resolve_agent_effort(agent)` в `agent_runs.effort` в момент запуска (BR-EFF) |
| `src/usage.py` | `short_model_name` (рядом — рендер эффорта); при необходимости — пробрасывать effort в строку стадии |
| `tests/test_notifications*.py`, `tests/test_*tracker*` | unit-покрытие |

**НЕ трогать** (BR-G6): `src/reconciler.py` / `tests/test_reconciler.py` — задача не требует их правок; пересечение с ORCH-86 неприемлемо. Если правка всё же понадобится — сохранить ORCH-086 (`skipped_terminal_total`, `state_uuid`-dedup, terminal-skip F-1) и явно проверить на merge-gate.

## 1. G0 — расследование (BR-G0) → ADR

- Исследование выполняется ДО кода: собрать факты по §4 BRD (логи орка `data/runs`, Telegram message_id, БД `tracker_message_id`/`stage` по ORCH-082), воспроизвести прогон на staging (8501), зафиксировать таблицу «стадия → (заголовок+тело в Telegram) vs (stage в БД)».
- Артефакт расследования и обоснованная рекомендация `bump` vs `edit` → `06-adr/ADR-NNN-tracker-orphan-cleanup.md`.
- Код фикса (G1–G3) реализует выбранный в ADR механизм. ТЗ ниже задаёт ИНВАРИАНТЫ, которым любой выбранный механизм обязан удовлетворять.

## 2. G1 — гарантированная зачистка сирот (BR-G1)

**Требование-инвариант:** после любого `update_task_tracker` в чате не остаётся НИ ОДНОЙ ранее созданной карточки этой задачи, кроме текущей (в пределах 48ч-лимита Telegram).

Точка проблемы (текущий код, `update_task_tracker`, ветка `mode == "bump"`):
```python
if mid is not None:
    delete_telegram(mid)            # удаляется ТОЛЬКО последний mid
new_mid = send_telegram(text, disable_notification=True)
if new_mid is not None:
    set_tracker_message_id(task_id, new_mid)
```
`tasks.tracker_message_id` — скаляр (последний `mid`). При рассинхроне (send→None / рестарт между delete и send / пересоздание / гонка / delete-fail+send-ok) прежние карточки теряют ссылку и осиротевают.

**Требования к решению (любой механизм из ADR):**
- R-1. Система должна знать обо ВСЕХ незакрытых `message_id` карточек задачи (а не только о последнем), чтобы подчищать их при следующем bump / на рассинхроне / при старте.
- R-2. Перед/в момент создания новой карточки удаляются ВСЕ известные незакрытые `message_id`; успешно удалённые (включая «already gone» по `_DELETE_GONE_MARKERS`) исключаются из учёта; не удалённые transient — остаются в учёте для повторной попытки.
- R-3. Новый `message_id` записывается в учёт ТОЛЬКО при успешном `send` (`new_mid is not None`) — transient send не должен обнулять/терять учёт (сохранить текущую защиту BR-6).
- R-4. Инвариант «одна карточка на задачу» и «не более одного `send` за вызов» сохраняются → дубликатов внутри вызова нет.
- R-5. **Кандидатные механизмы для ADR** (выбор за архитектором, не предрешать в коде до ADR):
  - (A) bump + полный учёт `message_id` (новая таблица `tracker_messages(task_id, message_id, created_at, deleted_at)` ИЛИ JSON-массив в колонке `tasks.tracker_message_ids`), зачистка всех незакрытых;
  - (B) переход дефолта на `edit` (нет сирот by design; теряется «карточка внизу» ORCH-042) — взвесить против фича-просьбы.
- R-6. Изменение схемы БД (если выбран вариант A) — строго аддитивное (`CREATE TABLE IF NOT EXISTS` / `_ensure_column`), идемпотентное, restart-safe на живой общей прод-БД (данные enduro не трогаются). Детали данных — `08-data-requirements.md`.

## 3. G2 — заголовок отражает текущую стадию (BR-G2)

- Рендер `render_task_tracker` уже строит заголовок/статус-строку из `tasks.stage` (`plane_status_label` → `_card_status_label`). Замёрзший `To Analyse` — следствие осиротевшей карточки (G1), а не бага рендера.
- Требование: после фикса G1 единственная живая карточка всегда несёт заголовок текущей стадии. Регресс-тест: на каждой стадии заголовок/статус-строка соответствуют `stage` в БД (часть staging-воспроизведения G0 + unit на `plane_status_label`).

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

- Проверить, что `_STAGE_STATUS_LABEL["deploy"]` (`⏸️ Awaiting Deploy — ожидание Confirm Deploy`) + live-overlay `_live_plane_branch_override` (`deploying`, `monitoring`) покрывают весь цикл `Awaiting Deploy → Deploying → Confirm Deploy → Monitoring → Done`.
- Если какой-то под-статус не отображается на соответствующей стадии — добить offline-label/overlay. `Done` рендерится из `stage == "done"`. Контракт never-raise и kill-switch `tracker_live_status` сохраняются.

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

**API/данные:**
- Новая колонка `agent_runs.effort TEXT` (миграция `_ensure_column(conn, "agent_runs", "effort", "TEXT")` в `src/db.py`, рядом с `model`).
- **Стамп в момент запуска** (`launcher._spawn`): сразу после резолва `effort = resolve_agent_effort(agent, project_id)` записать его в строку `agent_runs` (тот же `run_id`). Источник — РЕАЛЬНО ушедшее в `--effort` значение (`""`/без флага → сохранить пусто/`NULL`). Это надёжнее пересчёта (CLI не возвращает эффорт в result-JSON).
  - Допустимо: расширить `INSERT INTO agent_runs (task_id, agent, effort) VALUES (?,?,?)` или отдельным `UPDATE agent_runs SET effort=? WHERE id=?` после резолва. Выбор — архитектор; значение должно соответствовать фактическому флагу запуска.

**Рендер** (`render_task_tracker._stage_line`):
- Текущий суффикс: `f" · {model}"` при наличии модели.
- Добавить эффорт рядом: формат `· opus-4-8 · xhigh` ИЛИ компактно `· opus-4-8/xhigh` (на усмотрение, выбрать единый). При пустом эффорте — суффикс эффорта опускается (как опускается модель при пустой `short_model_name`).
- Брать `effort` из строки `agent_runs` соответствующей стадии (последний завершённый run, как `model`). Допустим fallback на `resolve_agent_effort(agent)` для исторических строк без колонки.

**Ожидаемо:** developer-строка → `xhigh`; tester/deployer → `medium`; analyst/architect/reviewer → `high` (по таблице ORCH-41/081).

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

Текущая итоговая строка (`done`):
```python
wall = _duration_seconds(created_at, updated_at)          # раздут: вся очередь+ожидание+застой
review_seconds = _duration_seconds(brd_review_started, brd_review_ended)  # раздут при застое
"⏱️ Всего {wall} · агенты {agent_seconds} · твоё {review}"
```
Проблема: `wall ≠ agent_seconds + review_seconds` (незалогированные queue-паузы) → итог визуально «врёт»; `review_seconds` засчитывает застой/рассинхрон (ORCH-087: 392м).

**Требования (формула — за архитектором, G5 «КАК — архитектору»):**
- T-1. Чистое рабочее время агентов = `Σ _duration_seconds(started, finished)` по `agent_runs` (текущий `agent_seconds`) — **главная метрика**, оставить точной.
- T-2. Человеческое BRD-время — ТОЛЬКО фактическое: НЕ включать аномальный застой/рассинхрон (`brd_review` болтался открытым из-за рассинхрона In Review→Backlog). Ограничить разумным порогом ИЛИ считать только активные окна. Аномалия не должна показываться как «твоё время».
- T-3. Wall-clock — если показываем, помечать как «общее (с ожиданием)», НЕ выдавать за рабочее время.
- T-4. Итог должен СХОДИТЬСЯ: либо `wall = Σ(стадии) + Σ(паузы с подписью)`, либо не показывать wall как сумму. Прозрачность вместо «магического» числа.
- T-5. `agent_runs`-агрегация (`total_in/total_out/total_cost/agent_seconds`) и `💰`-строка — без регресса.

## 7. Изменения API (endpoints)

Нет новых/изменённых HTTP-endpoint. (Опционально — отразить учёт карточек/effort в read-only снимке `GET /queue`, если архитектор сочтёт нужным; не обязательно.)

## 8. Изменения схемы БД

- `agent_runs.effort TEXT` — аддитивно, идемпотентно (`_ensure_column`). **Обязательно.**
- Учёт `message_id` (BR-G1, если выбран вариант A) — аддитивная таблица `tracker_messages` ИЛИ колонка-массив `tasks.tracker_message_ids`. **Зависит от ADR.** Подробности — `08-data-requirements.md`.
- Существующие колонки/таблицы (`tasks.tracker_message_id`, `brd_review_*`, `agent_runs.model`) — не ломать; при варианте A сохранить обратную совместимость со скалярным `tracker_message_id` (миграция/со-существование).

## 9. Требования к новым QG-проверкам

Нет. `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, машинные вердикты гейтов — без изменений.

## 10. Артефакты pipeline, создаваемые/обновляемые

- `06-adr/ADR-NNN-tracker-orphan-cleanup.md` (G0 вывод + рекомендация bump/edit + механизм G1 + формула G5) — архитектор.
- Обновить `CLAUDE.md` (§ Нотификации) и `docs/architecture/README.md` (компонент Notifications) — отразить учёт карточек, эффорт-в-строке, честное время. **Golden source наравне с кодом.**
- `CHANGELOG.md` — `## [Unreleased]` запись (под `.gitattributes merge=union`).

## 11. Инварианты (не нарушать)

- never-raise во всём пути нотификаций; карточка всегда silent (`disable_notification`).
- «одна карточка на задачу»; ≤1 `send` за вызов `update_task_tracker`.
- Ссылки ORCH-067 (`plane_issue_link`), `disable_web_page_preview` ORCH-080 — сохранены.
- `STAGE_TRANSITIONS` / `QG_CHECKS` / стадии конвейера — без изменений.
- БР-G6: разработка/merge поверх свежего `origin/main` (ORCH-86); `reconciler.py` не эродировать.
- Миграции БД аддитивны и идемпотентны (общая прод-БД, enduro не трогать).
