Files
orchestrator/docs/work-items/ORCH-095/01-brd.md

14 KiB
Raw Permalink Blame History

work_item, stage, author_agent, status, created_at, model_used
work_item stage author_agent status created_at model_used
ORCH-095 analysis analyst ready-for-review 2026-06-09 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-тег → парсинг падает с 400edit_telegram возвращает EDIT_FAILEDupdate_task_tracker по ветке EDIT_FAILED (notifications.py:733-739) делает return, не отправляя новую карточку (защита от дублей, ORCH-087) → карточка застывает на стейте, где <1м впервые попал в текст.

Цепочка отказа (по коду): _fmt_minutes(<60s) → "<1м" → интерполируется в HTML без экранирования → editMessageText 400 can't parse entitiesedit_telegram → EDIT_FAILEDupdate_task_tracker ранний return → карточка не обновляется до конца жизни задачи.

Почему проявляется не на каждой задаче. Баг ловится только когда хотя бы одна длительность стадии < 1 мин (seconds < 60) и эта строка попадает в текст, который затем редактируется. Карточки ORCH-090/091 редактировались успешно (на момент edit в их тексте <1м не было); ORCH-093 — упала. Это объясняет «плавающую» природу симптома.

Корневой класс дефекта — шире одного <1м. Текст карточки собирается с parse_mode=HTML из смеси (а) намеренной разметки-обёртки (<a href> номер задачи, <b>) и (б) подставляемых данных. Намеренная разметка экранироваться не должна; данные — должны. Сейчас экранирован только заголовок (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-безопасному виду (экранированный &lt;1м ИЛИ переформулировка <1м~0м / < 1 мин с экранированием).
  • Сохранить работоспособность намеренной разметки карточки (<a href> номер задачи, жирный/прочее форматирование) — экранируются только данные, не обёртка.
  • Восстановить обновления уже застрявших карточек (после фикса карточка возобновляет обновления или переотправляется свежей).
  • Юнит-покрытие 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 мин, должна успешно редактироваться (editMessageText200, не 400 can't parse entities). Источник отказа — литерал <1м от _fmt_minutes — устранён. (⇒ G1, G2)
  • BR-2Все динамические значения, вставляемые в текст карточки с parse_mode=HTML (длительности, метрики токенов/стоимости, имя модели/эффорта, имена/лейблы стадий, статус-лейбл, заголовок задачи), HTML-безопасны: символы < > & в данных не интерпретируются Telegram как разметка. (⇒ G1)
  • BR-3 — Длительность «меньше минуты» рендерится так, чтобы не выглядеть открывающим HTML-тегом: экранированный &lt;1м ИЛИ переформулировка (~0м / < 1 мин) с экранированием. Видимое оператору значение остаётся осмысленным («меньше минуты»). (⇒ G2)
  • BR-4Регресс намеренной разметки: кликабельный номер задачи (<a href>, plane_issue_link) и любое форматирование-обёртка (<b> и т.п.) продолжают рендериться и оставаться кликабельными/валидными — экранируются только подставляемые данные, не разметка. (⇒ 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) → &amp;lt; в выводе. Митигировать на стадии архитектуры (экранировать ровно один раз на источник данных).
  • Случайное экранирование разметки-обёртки (<a>, <b>) → ссылки/жирный перестают работать (регресс BR-4). Чёткая граница «данные vs обёртка».
  • Изменение вида «<1м» меняет визуал карточки — согласовать формулировку с оператором (BR-3 допускает оба варианта).
  • Детали/перечень — 10-tech-risks.md (заполняет архитектор).