Files
orchestrator/docs/work-items/ORCH-087/06-adr/ADR-001-tracker-orphan-cleanup.md

24 KiB
Raw Blame History

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 переходов в худшем случае осиротеть может до N1 карточек. Точное число для конкретного прогона непредсказуемо именно потому, что учёта старых 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) sendNone (нет креды / 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_labelplane_status_label детерминированно выводит заголовок из tasks.stage (_STAGE_STATUS_LABEL), и на deploy корректно даёт ⏸️ Awaiting Deploy. Осиротевшая карточка 18204 была отрисована ОДИН раз на самой ранней стадии (stage ещё created/analysisTo 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):

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