From 36c7a68722811fbd9d3f7b9f338d08abfc9a8c1a Mon Sep 17 00:00:00 2001 From: claude-bot Date: Tue, 9 Jun 2026 09:06:00 +0300 Subject: [PATCH] architect(ET): auto-commit from architect run_id=429 --- docs/architecture/README.md | 36 +++ .../06-adr/ADR-001-tracker-orphan-cleanup.md | 288 ++++++++++++++++++ .../ORCH-087/08-data-requirements.md | 86 ++++++ docs/work-items/ORCH-087/10-tech-risks.md | 29 ++ 4 files changed, 439 insertions(+) create mode 100644 docs/work-items/ORCH-087/06-adr/ADR-001-tracker-orphan-cleanup.md create mode 100644 docs/work-items/ORCH-087/08-data-requirements.md create mode 100644 docs/work-items/ORCH-087/10-tech-risks.md diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 924de80..28654d5 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -334,6 +334,42 @@ helper `validated_revision` питает и штамп A, и `EXPECTED_REVISION` Подробнее: [adr-0012](adr/adr-0012-security-gate.md), детально — `docs/work-items/ORCH-022/06-adr/ADR-001-security-gate.md`. +### Live-трекер: зачистка сирот + эффорт в карточке + честное время (ORCH-087 — design) +Скалярный `tasks.tracker_message_id` (только последний `message_id`) при рассинхроне +bump-режима (доминанты: гонка двух `update_task_tracker` и delete-fail+send-ok) +терял ссылку на прежние карточки → **осиротевшие «замёрзшие»** карточки (скриншот +ORCH-082: `📍 To Analyse` на задаче, реально дошедшей до `deploy`). G0-расследование +([ADR-001](../work-items/ORCH-087/06-adr/ADR-001-tracker-orphan-cleanup.md)): +рендер исправен, корень — потеря учёта старых mid. Решение (bump сохраняется как +дефолт — фича «карточка внизу» ORCH-042/067): +- **G1 — полный учёт mid:** аддитивная таблица-леджер `tracker_messages(task_id, + message_id, created_at, deleted_at)` (вариант A1; JSON-массив A2 отклонён — + lost-update при гонке). На каждом bump зачищаются ВСЕ незакрытые mid (`deleted_at + IS NULL`): успех/«already gone» → `deleted_at`, transient → остаётся для ретрая; + новый mid в леджер + `set_tracker_message_id` ТОЛЬКО при `send is not None` (BR-6). + Скаляр `tracker_message_id` сохранён (BC). Остаточная гонка самозалечивается за один + переход (лок не вводится). Known-limitation: Telegram 48ч (сироты старше неудаляемы). +- **G2/G3 — заголовок/deploy-цикл:** после G1 единственная живая карточка несёт + заголовок текущей стадии; `_LIVE_BRANCH_LABELS` дополняется ключом `confirm_deploy` + (полнота цикла `Awaiting Deploy → Deploying → Confirm Deploy → Monitoring → Done`). +- **BR-EFF — эффорт в строке стадии:** новая колонка `agent_runs.effort TEXT`, + стамп фактического `resolve_agent_effort` в `launcher._spawn` (CLI эффорт не + возвращает); рендер `· {model} · {effort}` (developer=`xhigh`, tester/deployer= + `medium`, прочие=`high`); пустой → суффикс опускается. +- **BR-G5 — честное время:** done-строка `⏱️ Агенты {agent} · твоё {review~cap} · + общее с ожиданием {wall}` — три независимых подписанных метрики; `agent`=Σ + `agent_runs` (главная, точная); «твоё» ограничено порогом + `tracker_brd_review_cap_s` (дефолт 2ч, маркер `~` при отсечке аномального застоя); + `wall` подписан «с ожиданием», не выдаётся за сумму. +- **Инварианты:** `STAGE_TRANSITIONS`/`QG_CHECKS`/стадии — без изменений; миграции + аддитивны/идемпотентны (общая прод-БД, enduro не трогается); never-raise, + `disable_notification`, `plane_issue_link` (ORCH-067), `disable_web_page_preview` + (ORCH-080) — сохранены; разработка поверх свежего `origin/main` (ORCH-86), + `reconciler.py` не эродируется. + +Детально — [ADR-001](../work-items/ORCH-087/06-adr/ADR-001-tracker-orphan-cleanup.md), +`docs/work-items/ORCH-087/08-data-requirements.md`. + ### Reconciler: реконсиляция потерянных webhook (ORCH-053 — реализовано) Конвейер продвигается только входящими webhook; потерянное событие (502 на ребилде, нет ретраев у Plane/Gitea, неразрезолвленный `sha→branch`) → задача застревает молча diff --git a/docs/work-items/ORCH-087/06-adr/ADR-001-tracker-orphan-cleanup.md b/docs/work-items/ORCH-087/06-adr/ADR-001-tracker-orphan-cleanup.md new file mode 100644 index 0000000..21bbc02 --- /dev/null +++ b/docs/work-items/ORCH-087/06-adr/ADR-001-tracker-orphan-cleanup.md @@ -0,0 +1,288 @@ +# 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"`): +```python +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 — см. «Альтернативы»): + +```sql +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 точность» + в пользу неинтроду­цирования аномального застоя в «твоё время». diff --git a/docs/work-items/ORCH-087/08-data-requirements.md b/docs/work-items/ORCH-087/08-data-requirements.md new file mode 100644 index 0000000..7c39878 --- /dev/null +++ b/docs/work-items/ORCH-087/08-data-requirements.md @@ -0,0 +1,86 @@ +# Требования к схеме БД — ORCH-087 + +Все изменения — **строго аддитивные и идемпотентные** (`CREATE TABLE IF NOT EXISTS` +/ `_ensure_column`), restart-safe на живой ОБЩЕЙ прод-БД (SQLite). Данные +enduro-trails не трогаются. Существующие колонки/таблицы не ломаются. Точка врезки — +`src/db.py::init_db` (рядом с прочими `_ensure_column`/`executescript`). + +--- + +## 1. Колонка `agent_runs.effort` (BR-EFF, обязательно) + +```python +_ensure_column(conn, "agent_runs", "effort", "TEXT") +``` + +- Тип `TEXT`, nullable. Хранит РЕАЛЬНО ушедшее в `--effort` значение + (`low|medium|high|xhigh|max`) или `NULL`, если флаг не подавался (резолв вернул ""). +- Заполняется в `launcher._spawn` сразу после `resolve_agent_effort(agent, + project_id)` через `UPDATE agent_runs SET effort=? WHERE id=run_id` + (`effort or None`). +- Читается в `render_task_tracker` (добавить `effort` в SELECT `agent_runs`). +- Исторические строки (до миграции) → `effort IS NULL` → суффикс эффорта в карточке + опускается; допустим fallback на `resolve_agent_effort(run["agent"])`. +- Идемпотентность: `_ensure_column` — no-op при уже существующей колонке (AC-E.1, + TC-09). + +## 2. Таблица-леджер `tracker_messages` (BR-G1, вариант A1 ADR-001) + +```sql +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; +``` + +- Авторитетный учёт ВСЕХ созданных карточек задачи; `deleted_at IS NULL` ⇔ карточка + считается живой и подлежит зачистке на следующем bump. +- Логический FK на `tasks.id` без `REFERENCES` (зеркалит `jobs.task_id`/`job_deps`) — + миграция не падает на pre-existing БД. +- Частичный индекс `WHERE deleted_at IS NULL` — дешёвая выборка незакрытых mid в + горячем пути рендера/зачистки. +- `PRIMARY KEY (task_id, message_id)` — идемпотентность INSERT (повторный mid не + дублируется); защита от двойного учёта при гонке. + +**Новые геттеры/сеттеры в `src/db.py` (предложение, точная сигнатура — за разработчиком):** + +| Функция | Назначение | +|---------|-----------| +| `add_tracker_message(task_id, message_id)` | INSERT нового mid (после успешного `send`). `INSERT OR IGNORE` для идемпотентности. | +| `get_open_tracker_messages(task_id) -> list[int]` | Все `message_id` с `deleted_at IS NULL`. | +| `mark_tracker_message_deleted(task_id, message_id)` | `UPDATE … SET deleted_at=datetime('now')` для успешно удалённых / «already gone». | + +Контракт — как у существующих хелперов БД (never-raise по месту вызова в +notifications: ошибка БД не валит конвейер). + +### Сосуществование со скаляром `tasks.tracker_message_id` + +- `tasks.tracker_message_id` **СОХРАНЯЕТСЯ** без изменения семантики — указатель на + ТЕКУЩУЮ карточку (читатели `get_tracker_message_id`/`set_tracker_message_id` не + трогаются). Обратная совместимость полная. +- Леджер `tracker_messages` — НАДмножество: источник истины для зачистки сирот. +- Одноразовый бэкфилл скаляра в леджер **не требуется** (старые сироты всё равно за + 48ч-окном Telegram). Новый поток ведёт леджер с нуля. + +## 3. Что НЕ меняется + +- `tasks` (кроме отсутствия изменений — скаляр сохранён), `jobs`, `events`, + `job_deps`, прочие колонки `agent_runs` (`model`, токены, cost, exit_code) — без + изменений. +- Никаких `DROP`/`ALTER … DROP`/переименований/перетипизаций (SQLite-небезопасно на + живой БД). +- `STAGE_TRANSITIONS` / `QG_CHECKS` — вне зоны БД, не затрагиваются. + +## 4. Идемпотентность и restart-safety (проверка) + +- Двойной вызов `init_db` → без ошибок (`IF NOT EXISTS` / `_ensure_column` no-op) — + TC-09. +- Леджер переживает рестарт орка: незакрытые mid читаются из БД → следующий bump + подчищает старые карточки (TC-05, AC-1.3). +- Миграция на БД с существующими данными enduro: только добавляет колонку/таблицу, + данные нетронуты (AC-X.5). diff --git a/docs/work-items/ORCH-087/10-tech-risks.md b/docs/work-items/ORCH-087/10-tech-risks.md new file mode 100644 index 0000000..fae9c41 --- /dev/null +++ b/docs/work-items/ORCH-087/10-tech-risks.md @@ -0,0 +1,29 @@ +# Технические риски — ORCH-087 + +Зона изменений: `src/notifications.py`, `src/db.py`, `src/agents/launcher.py`, +`src/usage.py`, тесты. Машина стадий и QG не затрагиваются. Контракт компонента — +never-raise, карточка silent. + +| ID | Риск | Вероятность / Влияние | Митигация | +|----|------|------------------------|-----------| +| R-1 | **Self-hosting:** задача правит инструмент в проде, обслуживающем enduro-trails из общей БД/очереди. Регресс пути нотификаций мог бы испортить наблюдаемость всех проектов. | Низк. / Сред. | never-raise сохранён по всему пути; обязательный `deploy-staging` (8501) гейт перед прод-деплоем; нотификации не на критическом пути конвейера (ошибка не валит стадии). | +| R-2 | **Telegram 48ч-лимит:** сироты старше 48ч неудаляемы → могут остаться навсегда. | Сред. / Низк. | Документировано как known-limitation (ADR §Последствия, AC-1.4); `_DELETE_GONE_MARKERS` классифицирует как «gone» → исключает из учёта, не зацикливает ретраи. Касается только сирот ДО деплоя фикса. | +| R-3 | **Гонка (d)** двух `update_task_tracker` (queue-worker / reconciler / reaper) → лишняя карточка на один переход. | Сред. / Низк. | Леджер самозалечивается на следующем bump (ADR Р-2); строго лучше текущего постоянного сиротства; кросс-процессный лок сознательно не вводится (цена > выгоды для silent-карточки). | +| R-4 | **Миграция на живой общей прод-БД** (SQLite). Неаддитивная правка могла бы тронуть данные enduro. | Низк. / Выс. | Только `CREATE TABLE IF NOT EXISTS` / `_ensure_column` (идемпотентно, no-op при существовании); никаких DROP/ALTER DROP/переименований; логический FK без `REFERENCES` (не падает на pre-existing БД). TC-09 проверяет идемпотентность. | +| R-5 | **BR-G6 / merge-gate:** ветка должна жить поверх свежего `origin/main` (ORCH-86); эрозия `reconciler.py` затёрла бы terminal-skip/`state_uuid`-dedup. | Низк. / Выс. | Подтверждено: origin/main — предок HEAD; 43 маркера ORCH-086 на месте; файлы ORCH-087 НЕ пересекают `reconciler.py`. `CHANGELOG.md` под `.gitattributes merge=union`. Явная проверка merge-gate — TC-19 (AC-6.1/6.2). | +| R-6 | **Порог `tracker_brd_review_cap_s`** (дефолт 2ч): легитимный человеческий BRD-review длиннее 2ч отобразится как `~2ч` (недо-отчёт). | Сред. / Низк. | Конфигурируем (env); компромисс в пользу неинтродуцирования аномального застоя в «твоё время». Маркер `~` сигнализирует отсечку. Главная метрика (агенты) остаётся точной. | +| R-7 | **Стамп эффорта в `_spawn`:** доп. `UPDATE agent_runs` сразу после INSERT мог бы упасть и сорвать запуск агента. | Низк. / Сред. | `UPDATE` по существующему `run_id` в уже открытом соединении; в худшем случае effort=NULL → суффикс опускается (рендер не падает, AC-E.4). Эффорт — наблюдаемость, не функциональность запуска. | +| R-8 | **Регресс существующих тестов нотификаций** (новый формат строки стадии с эффортом + новая done-строка времени). | Сред. / Низк. | Обновить ожидания в `tests/test_notifications*.py`; новый формат строго аддитивен (суффикс эффорта/подписи времени). TC-11…TC-15. | +| R-9 | **Live-overlay `confirm_deploy`:** новый ключ overlay при отсутствии UUID статуса в проекте мог бы шуметь/падать. | Низк. / Низк. | overlay never-raise, деградирует на offline-label при отсутствии UUID/ошибке; kill-switch `tracker_live_status`; без base-alias (реальный отдельный статус). | + +## Острые точки внимания для разработчика + +1. **Порядок в bump:** зачистка ВСЕХ открытых mid из леджера → `send` → INSERT+repoint + ТОЛЬКО при `new_mid is not None` (R-3/BR-6). Ровно один `send` за вызов (R-4). +2. **never-raise:** любая ошибка БД-леджера / Telegram внутри `update_task_tracker` + гасится (как сейчас) — конвейер не падает (TC-16, AC-X.2). +3. **Эффорт = фактический флаг:** хранить `resolve_agent_effort(...)` как ушло в + `--effort` (пусто → NULL), а не пересчёт постфактум (CLI не возвращает эффорт). +4. **Не трогать** `reconciler.py`/`tests/test_reconciler.py` (BR-G6). +5. **Сохранить** `plane_issue_link` (ORCH-067) и `disable_web_page_preview` (ORCH-080) + в payload (TC-17, AC-X.4).