24 KiB
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"):
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 — см. «Альтернативы»):
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):
- Прочитать ВСЕ незакрытые mid задачи:
SELECT message_id FROM tracker_messages WHERE task_id=? AND deleted_at IS NULL(R-1). - Для каждого:
delete_telegram(mid):True(удалено ИЛИ_DELETE_GONE_MARKERS«already gone», вкл. >48ч) →UPDATE … SET deleted_at=datetime('now')(исключить из учёта, R-2);False(transient/сеть/5xx) → оставить незакрытой для повторной попытки на следующем bump (R-2).
new_mid = send_telegram(text, disable_notification=True)— РОВНО один send (R-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-switchtracker_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-upUPDATE(а не расширениеINSERTна line 449) — минимальный диф, без переноса резолва модели/эффорта выше по коду; значение точно соответствует флагу запуска. CLI не возвращает эффорт в result-JSON, поэтому стамп — единственный надёжный источник (BR §6). - Рендер (
render_task_tracker._stage_line): добавитьeffortв SELECTagent_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-флаг, envORCH_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_previewORCH-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 точность» в пользу неинтродуцирования аномального застоя в «твоё время».