155 lines
14 KiB
Markdown
155 lines
14 KiB
Markdown
---
|
||
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`
|
||
из смеси (а) намеренной разметки-обёртки (`<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-безопасному виду (экранированный `<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 мин, должна
|
||
успешно редактироваться (`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** — **Регресс намеренной разметки:** кликабельный номер задачи (`<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`) → `&lt;` в выводе. Митигировать на стадии архитектуры (экранировать
|
||
ровно один раз на источник данных).
|
||
- **Случайное экранирование разметки-обёртки** (`<a>`, `<b>`) → ссылки/жирный перестают
|
||
работать (регресс BR-4). Чёткая граница «данные vs обёртка».
|
||
- Изменение вида «<1м» меняет визуал карточки — согласовать формулировку с оператором (BR-3
|
||
допускает оба варианта).
|
||
- Детали/перечень — `10-tech-risks.md` (заполняет архитектор).
|