--- work_item: ORCH-095 stage: analysis author_agent: analyst status: ready-for-review created_at: 2026-06-09 model_used: claude-opus-4-8 --- # 01 — BRD (бизнес-требования): ORCH-095 — HTML-инъекция «<1м» в render_task_tracker застывает live-карточку Work Item: **ORCH-095** · Repo: **orchestrator** · Стадия: analysis ## 1. Бизнес-контекст и проблема Live-трекер задачи (`src/notifications.py::render_task_tracker`) — **основной канал видимости конвейера для оператора**. Слава узнаёт состояние каждой задачи по её единственной карточке в Telegram (инвариант «одна карточка на задачу», ORCH-042/067/087). Если карточка перестаёт обновляться — оператор слепнет: задача реально идёт/завершилась, а карточка врёт. **Установленный факт (воспроизведён детерминированно 09.06, сырой ответ Telegram).** Прямой вызов `editMessageText` для застрявшей карточки ORCH-093 (`message_id 18854`) вернул: ``` 400 Bad Request: can't parse entities: Unsupported start tag "1м" at byte offset 500 ``` В тексте карточки на позиции ~379 присутствует подстрока `<1м · …` — длительность стадии «меньше одной минуты», которую `_fmt_minutes` (`src/notifications.py:288-289`) рендерит как литерал **`<1м`**. Карточка отправляется с `parse_mode=HTML` (`editMessageText`, `notifications.py:175`). Telegram трактует `<1м` как **открывающий HTML-тег** → парсинг падает с `400` → `edit_telegram` возвращает `EDIT_FAILED` → `update_task_tracker` по ветке `EDIT_FAILED` (`notifications.py:733-739`) делает `return`, **не** отправляя новую карточку (защита от дублей, ORCH-087) → карточка **застывает** на стейте, где `<1м` впервые попал в текст. **Цепочка отказа** (по коду): `_fmt_minutes(<60s) → "<1м"` → интерполируется в HTML без экранирования → `editMessageText` `400 can't parse entities` → `edit_telegram → EDIT_FAILED` → `update_task_tracker` ранний `return` → карточка не обновляется до конца жизни задачи. **Почему проявляется не на каждой задаче.** Баг ловится **только** когда хотя бы одна длительность стадии < 1 мин (`seconds < 60`) и эта строка попадает в текст, который затем редактируется. Карточки ORCH-090/091 редактировались успешно (на момент `edit` в их тексте `<1м` не было); ORCH-093 — упала. Это объясняет «плавающую» природу симптома. **Корневой класс дефекта — шире одного `<1м`.** Текст карточки собирается с `parse_mode=HTML` из смеси (а) намеренной разметки-обёртки (`` номер задачи, ``) и (б) подставляемых **данных**. Намеренная разметка экранироваться **не должна**; данные — должны. Сейчас экранирован только заголовок (`esc_title`, `notifications.py:428`) и href/label внутри `plane_issue_link`. Прочие данные — длительности (`_fmt_minutes`), метрики токенов/стоимости (`fmt_tokens`/`fmt_cost`), имя модели (`short_model_name`), статус-лейбл (`_card_status_label`) — вставляются **без** `html.escape`. `<1м` — первый сработавший экземпляр этого класса; задача закрывает класс, а не единичный символ. ## 2. Объём (scope) ### В объёме - Устранить HTML-инъекцию в `render_task_tracker`: любые **данные**, попадающие в текст карточки с `parse_mode=HTML`, не должны ломать парсер Telegram (`< > &` в данных безопасны). - Привести формат «длительность < 1 мин» к HTML-безопасному виду (экранированный `<1м` ИЛИ переформулировка `<1м` → `~0м` / `< 1 мин` с экранированием). - Сохранить работоспособность **намеренной** разметки карточки (`` номер задачи, жирный/прочее форматирование) — экранируются только данные, не обёртка. - Восстановить обновления уже застрявших карточек (после фикса карточка возобновляет обновления или переотправляется свежей). - Юнит-покрытие HTML-безопасности всех динамических полей; зелёный регресс `pytest tests/ -q`; запись в `CHANGELOG.md`. ### Вне объёма - Изменение `STAGE_TRANSITIONS`, `QG_CHECKS`, `check_*`, схемы БД — **не трогаются** (баг чисто в слое рендера уведомлений). - Изменение режима трекера (`bump`/`edit`), логики леджера сирот (ORCH-087), статусной модели ORCH-066, транспортных примитивов (`send_telegram`/`edit_telegram`/`delete_telegram`) — кроме точечной HTML-безопасности самого текста. - Редизайн раскладки/состава карточки, новые метрики, перевод строк. - Изменение машинных вердиктов / frontmatter-контракта. ## 3. Заинтересованные стороны - **Заказчик / репортёр:** Слава (оператор) — обнаружил баг 09.06 (карточка ORCH-093 застряла, «по 91 уже нету»). - **Затронуты:** все наблюдатели Telegram-трекера по **всем** проектам (self-hosting: общий прод-инстанс обслуживает и enduro-trails — карточки их задач так же уязвимы при стадии < 1 мин). - **Принимает результат:** reviewer/tester конвейера ORCH; финальная приёмка — оператор (карточки снова обновляются в реальном времени). ## 4. Бизнес-требования (BR) - **BR-1** — Карточка трекера, в тексте которой есть стадия длительностью < 1 мин, должна успешно редактироваться (`editMessageText` → `200`, не `400 can't parse entities`). Источник отказа — литерал `<1м` от `_fmt_minutes` — устранён. (⇒ G1, G2) - **BR-2** — **Все** динамические значения, вставляемые в текст карточки с `parse_mode=HTML` (длительности, метрики токенов/стоимости, имя модели/эффорта, имена/лейблы стадий, статус-лейбл, заголовок задачи), HTML-безопасны: символы `< > &` в **данных** не интерпретируются Telegram как разметка. (⇒ G1) - **BR-3** — Длительность «меньше минуты» рендерится так, чтобы не выглядеть открывающим HTML-тегом: экранированный `<1м` **ИЛИ** переформулировка (`~0м` / `< 1 мин`) с экранированием. Видимое оператору значение остаётся осмысленным («меньше минуты»). (⇒ G2) - **BR-4** — **Регресс намеренной разметки:** кликабельный номер задачи (``, `plane_issue_link`) и любое форматирование-обёртка (`` и т.п.) продолжают рендериться и оставаться кликабельными/валидными — экранируются только подставляемые данные, не разметка. (⇒ G3) - **BR-5** — Уже застрявшая карточка (класс ORCH-093) после деплоя фикса **возобновляет обновления**: либо успешный `editMessageText` на следующем переходе стадии, либо переотправка свежей карточки. Конкретный механизм восстановления (текст снова валиден → edit проходит, ИЛИ классификация `can't parse entities` как пересоздаваемой) — решение архитектора; бизнес-требование — карточка перестаёт быть «замёрзшей сиротой». (⇒ G... / AC-4) ## 5. Нефункциональные требования (NFR) - **NFR-1 (never-raise):** `render_task_tracker` и весь путь уведомлений сохраняют контракт «никогда не роняют конвейер» — любая ошибка рендера/экранирования деградирует к fallback-строке, не исключение. - **NFR-2 (нулевая регрессия разметки):** существующие зелёные тесты трекера (`test_telegram_tracker.py`, `test_tracker_*`, `test_notifications_orphans.py`, `test_notify_issue_links.py`) остаются зелёными; кликабельность номера и формат строк не деградируют визуально (кроме намеренной смены вида «<1м»). - **NFR-3 (self-hosting):** фикс — изменение **только** слоя рендера уведомлений; прод-контейнер `orchestrator` не перезапускается в рамках стадий разработки; обязательна страховка `deploy-staging` перед прод-деплоем. Машина стадий/гейты/схема БД не затрагиваются. - **NFR-4 (совместимость):** изменение обратносовместимо по данным/схеме; не требует миграций; применяется к новым рендерам сразу после деплоя. ## 6. Допущения и ограничения - Карточка всегда отправляется с `parse_mode=HTML` (`send_telegram:58`, `edit_telegram:175`) — это инвариант (ссылки/жирный требуют HTML); переход на `parse_mode=None`/MarkdownV2 **не** рассматривается (сломает намеренную разметку, шире объёма). - `fmt_tokens`/`fmt_cost` сейчас выдают только цифры/`.`/`k`/`M`/`$` (HTML-безопасно), но требование BR-2 покрывает их **defence-in-depth** на случай будущих изменений формата. - Telegram-лимит 48ч: карточки старше 48ч физически неудаляемы/неперезаписываемы — для них восстановление недостижимо (known-limitation, унаследовано от ORCH-087); BR-5 относится к карточкам в пределах окна. - Источник `<1м` — `_fmt_minutes` (единственная функция, эмитящая литерал `<`); прочие данные лишь потенциально опасны. Точка(и) внесения экранирования — решение архитектора (централизовать в `_fmt_minutes`/на точке рендера/обёрткой-хелпером). ## 7. Критерии успеха Карточка задачи со стадией < 1 мин успешно редактируется (нет `400 can't parse entities`); все динамические поля HTML-безопасны; намеренная разметка (ссылка-номер, форматирование) рендерится и кликабельна; застрявшие карточки возобновляют обновления; `never-raise` сохранён; `pytest tests/ -q` зелёный; `CHANGELOG.md` обновлён. Детальные PASS/FAIL — `03-acceptance-criteria.md`. ## 8. Риски - **Двойное экранирование** уже экранированных полей (`esc_title`, href/label в `plane_issue_link`) → `&lt;` в выводе. Митигировать на стадии архитектуры (экранировать ровно один раз на источник данных). - **Случайное экранирование разметки-обёртки** (``, ``) → ссылки/жирный перестают работать (регресс BR-4). Чёткая граница «данные vs обёртка». - Изменение вида «<1м» меняет визуал карточки — согласовать формулировку с оператором (BR-3 допускает оба варианта). - Детали/перечень — `10-tech-risks.md` (заполняет архитектор).