diff --git a/CHANGELOG.md b/CHANGELOG.md index cd27836..522fc1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,13 @@ Формат: [Keep a Changelog](https://keepachangelog.com/). Записи — на смысловой PR/задачу. ## [Unreleased] +- **CI-фикс: per-run путь логов из хардкода `/app/data/runs` в `settings.runs_dir`** (ORCH-087, `fix`): тест `tests/test_launcher.py::TestEffortStamp::test_spawn_stamps_resolved_effort` падал в CI (`PermissionError: [Errno 13] … '/app'`) — зелёный локально-в-контейнере (где `/app` есть), красный на CI-хосте (act_runner hostexecutor, юзер без доступа к `/app`). **Корень:** `launcher._spawn` хардкодил `output_path="/app/data/runs/{run_id}.log"` + `os.makedirs('/app/data/runs')`, а тест дёргал `_spawn`, не замокав путь → makedirs на недоступном `/app` бросал. **Фикс (корень, не только тест):** базовый каталог per-run логов вынесен в `Settings.runs_dir` (env `ORCH_RUNS_DIR`, дефолт `/app/data/runs` — прод-layout 1:1); новый хелпер `launcher._run_log_path(run_id)` = `/{run_id}.log` стал единым источником пути (использован в `_spawn` + три прежних inline-строки логов/алертов). Тест `monkeypatch`-ит `settings.runs_dir` на `tmp_path` → окружение-независим (подтверждено прогоном с принудительно недоступным `/app`). `STAGE_TRANSITIONS`/`QG_CHECKS`/схема БД — без изменений. Документация: `README.md` (таблица env), `CHANGELOG.md`. +- **Live-трекер: зачистка осиротевших карточек + эффорт в строке стадии + честное итоговое время** (ORCH-087, `fix`): в чат периодически попадали «замёрзшие» сироты — старая карточка с заголовком `📍 To Analyse` висела на задаче, реально дошедшей до `deploy` (скриншот ORCH-082). **Корень (G0/ADR-001):** указатель `tasks.tracker_message_id` — скаляр (знает лишь ПОСЛЕДНИЙ `message_id`), поэтому при рассинхроне bump-режима (доминанты: гонка двух `update_task_tracker` и `delete`-fail+`send`-ok) ссылка на прежнюю карточку терялась навсегда → сирота не удалялась и больше не обновлялась (рендер исправен — застывал именно потерянный mid). **Фикс (bump сохранён дефолтом — фича «карточка внизу» ORCH-042/067):** + - **G1 — полный учёт mid:** аддитивная таблица-леджер `tracker_messages(task_id, message_id, created_at, deleted_at)` (`src/db.py`) + хелперы `add_tracker_message`/`get_open_tracker_messages`/`mark_tracker_message_deleted`. На каждом bump зачищаются ВСЕ незакрытые mid (`deleted_at IS NULL`), а не только скаляр: успех/«already gone» (`_DELETE_GONE_MARKERS`) → `deleted_at`; transient-`delete` → остаётся для ретрая; новый mid в леджер + `set_tracker_message_id` ТОЛЬКО при успешном `send` (R-3/BR-6). Остаточная гонка самозалечивается за один переход (лок не вводится). Скаляр `tracker_message_id` сохранён (BC). Known-limitation: Telegram 48ч (сироты старше неудаляемы). + - **G3 — deploy-цикл:** в `_LIVE_BRANCH_LABELS` добавлен ключ `confirm_deploy` («⏳ Confirm Deploy — подтвердите прод-деплой», без base-alias) → полнота `Awaiting Deploy → Deploying → Confirm Deploy → Monitoring → Done`. + - **BR-EFF — эффорт в строке стадии:** новая колонка `agent_runs.effort TEXT` (`_ensure_column`, идемпотентно); стамп фактического `resolve_agent_effort` в `launcher._spawn` в момент запуска (CLI эффорт в result-JSON не возвращает); рендер `· {model} · {effort}` (developer=`xhigh`, tester/deployer=`medium`, прочие=`high`); пустой effort → суффикс опускается. + - **BR-G5 — честное итоговое время:** done-строка `⏱️ Агенты {Σ agent_runs} · твоё {review~cap} · общее с ожиданием {wall}` — три независимых подписанных метрики (раньше `Всего {wall}` читалось как сумма, которой не является — queue-паузы не логируются). «Твоё» ограничено порогом `tracker_brd_review_cap_s` (env `ORCH_TRACKER_BRD_REVIEW_CAP_S`, дефолт 2ч; маркер `~` при отсечке аномального застоя из-за рассинхрона In Review→Backlog); `wall` подписан «с ожиданием». + - **Инварианты:** `STAGE_TRANSITIONS`/`QG_CHECKS`/стадии — без изменений; миграции аддитивны/идемпотентны (общая прод-БД, enduro не трогается); never-raise, `disable_notification`, `plane_issue_link` (ORCH-067), `disable_web_page_preview` (ORCH-080) сохранены; `src/reconciler.py` не эродирован (ORCH-086 на месте). Тесты: `tests/test_notifications_orphans.py` (TC-01..05 + never-raise), `tests/test_tracker_effort_time.py` (TC-06/11..15 + confirm_deploy), `tests/test_launcher.py::TestEffortStamp` (TC-09/10). ADR `docs/work-items/ORCH-087/06-adr/ADR-001-tracker-orphan-cleanup.md`. - **Терминал-скип и `state_uuid`-dedup на пути F-1 реконсилятора** (ORCH-086, `fix`): в Telegram периодически (особенно после рестарта орка) прилетало ложное `🔧 reconciler: ET-002 done разблокирована (потерян webhook)` — задача давно завершена, ничего не разблокируется, это шум. **Корень:** ORCH-068 закрыл livelock только на F-2 (plane-side); путь F-1 (gate-side) остался непокрытым по двум причинам — (A) вызов `_note_unblock(work_item_id, stage)` шёл без `state_uuid`, поэтому in-memory dedup пропускался; (B) единственным «терминал-фильтром» F-1 была выборка `get_active_tasks_for_reconcile` (`WHERE stage != 'done'`), не знающая о статусе issue в Plane — задача с дрейфом «БД орка не-`done`, а Plane уже `Done`» проходила фильтр, no-op условные гейты (enduro) давали зелёный → `advance` → ложное уведомление. **Фикс (ADR-001, локализован в `src/reconciler.py`):** (D1) новый `_resolve_issue_status(task)` делает **один** сетевой резолв Plane-статуса задачи за тик `(states, groups, state_uuid)` после дешёвых локальных гардов (busy/young/escalated в Plane не ходят), never-raise → `({}, {}, None)` при сбое; (D2) безусловный терминал-скип ДО Guard 2 — терминальная задача (группа Plane `completed`/`cancelled`, fallback на логические ключи `done`/`cancelled`, ЛИБО стадия в БД орка ∈ `{done, cancelled}`, т.к. `cancelled` не отсекается выборкой) → ранний `return` + `skipped_terminal_total++`, не подчинён `reconcile_skip_blocked_enabled` (тот гейтит только Guard 2); (D3) `_is_blocked_or_needs_input` переиспользует резолв D1 (3-й/4-й опц. аргументы; при `_UNSET` — самостоятельный резолв для прямых/легаси-вызовов, поведение 1:1); (D4) вызов `_note_unblock` на F-1 теперь передаёт `state_uuid` → dedup работает и на F-1 (повтор того же `issue_id`+`state_uuid` → `deduped_total++`, без второго Telegram). Терминальность — тот же `_is_terminal_state`, что и в F-2 (первичный дискриминатор — группа Plane, устойчив к UUID-алиасингу/мультипроектности; покрывает enduro и orchestrator). Анти-регресс (AC-4): легитимный unblock реально застрявшей не-терминальной задачи по-прежнему `advance` + ровно один Telegram (`unblocked_total++`). `STAGE_TRANSITIONS`, `QG_CHECKS`, схема БД, сигнатуры `advance_stage`/`advance_if_gate_passed`/`_note_unblock`, форма `status()`/`GET /queue`, новые config-флаги — без изменений; never-raise сохранён. ADR `docs/work-items/ORCH-086/06-adr/ADR-001-reconciler-f1-terminal-skip-and-dedup.md`. Тесты: `tests/test_reconciler.py` (TC-86-01..09/11: терминал по группе completed/cancelled, fallback по логическому ключу, DB-side cancelled, проброс/dedup `state_uuid`, анти-регресс, never-raise, независимость от Guard-2-флага), `tests/test_reconciler_plane.py` (TC-86-10: форма `status()` неизменна). Документация: `docs/architecture/README.md` (раздел Reconciler F-1). - **Подавление Telegram link-preview в карточке трекера / уведомлениях** (ORCH-080): под каждой карточкой трекера (`bump` и `edit`) и под notify/alert-сообщениями Telegram разворачивал баннер «Plane — Modern project management» для кликабельной ссылки `ORCH-NNN` на issue. В дефолтном `bump`-режиме (ORCH-067) карточка пересоздаётся на каждом переходе → баннер дублировался и засорял ленту (жалоба Owner, 08.06). **Корень:** JSON-payload обоих низкоуровневых примитивов `notifications.send_telegram` (`POST /sendMessage`) и `notifications.edit_telegram` (`POST /editMessageText`) не содержал ключ `disable_web_page_preview`. **Фикс (ADR-001, минимальная аддитивная правка на уровне примитива):** добавлен `"disable_web_page_preview": True` в payload обоих методов — гасит баннер у ВСЕХ потребителей (`update_task_tracker` в обоих режимах, `notify_approve_requested`, `notify_error`, alert'ы стадий из `launcher`/`stage_engine`) без изменения их кода. Безусловно, без kill-switch (превью трекера не нужно никому, риск нулевой). `parse_mode: "HTML"` сохранён в обоих payload → ссылка `ORCH-NNN` остаётся кликабельной; `disable_notification` (карточка тихая), bump/edit-логика, инвариант «одна карточка на задачу», контракты возврата (`send_telegram → message_id|None`, `edit_telegram → EDIT_*`) и never-raise — не затронуты. `STAGE_TRANSITIONS`, `QG_CHECKS`, схема БД — без изменений. ADR `docs/work-items/ORCH-080/06-adr/ADR-001-disable-telegram-link-preview.md`. Тесты: `tests/test_link_preview_disabled.py` (TC-01..06: флаг в обоих payload, регрессия `parse_mode`/полей, контракты возврата, never-raise). Документация: `CLAUDE.md` + `docs/architecture/README.md` (компонент Notifications). - **Гарантированный идемпотентный код-PR перед merge-verify (фикс ложного HOLD «no open PR»)** (ORCH-082/ORCH-81): закрыт отсутствующий инвариант «к моменту merge-verify у ветки есть открытый код-PR». **Корень (ORCH-074, 08.06):** PR создавался единственной `launcher._ensure_pr` ТОЛЬКО на developer-пути и ТОЛЬКО при свежем worktree-коммите (`exit==0 → git status непуст → commit → push → agent=="developer"`); после ручных восстановлений `main` у ветки ORCH-074 не оказалось открытого код-PR → детерминированный `merge_gate.merge_pr` вернул `("False", "no open PR")` → защита ORCH-073 верно удержала задачу (HOLD, не ложный `done`), но лечила следствие. **Фикс (ADR-001, аддитивно, внутри того же под-гейта merge-verify, машина стадий не тронута):** (1) новый идемпотентный leaf-актор `merge_gate.ensure_open_pr(repo, branch) -> (status, detail)` (never-raise): `GET …/pulls?state=open` с фильтром **`head.ref==branch` И `base.ref=="main"`** (идентичен `merge_pr`/ORCH-073 FR-3 — авто-docs-PR `base!=main` НЕ код-PR) → `("existed", N)`; иначе `POST …/pulls` → `("created", N)`; гонка `409/422` «PR exists» → повторный GET → `existed` (без дублей); любая иная HTTP/parse/сетевая ошибка → `("failed", reason)`. (2) Врезка в `stage_engine._handle_merge_verify` ПОСЛЕ резолва `validated_revision` и ПЕРЕД `merge_pr`: при `merge_verify_autocreate_pr_enabled` → `ensure_open_pr`; `created|existed` → штатно к `merge_pr` → `verify_merged_to_main`; `failed` → честный HOLD через новый helper `_hold_pr_create_failed` (текст «PR создать не удалось», `result.note="pr-create-failed-hold"` — текстуально отличим от not-merged HOLD; задача остаётся на `deploy`, НЕ `done`, БЕЗ отката на development). (3) `launcher._ensure_pr` делегирован в `merge_gate.ensure_open_pr` (единый код создания PR, общий фильтр `head==branch & base==main`); триггер «создавать только на developer-пути со свежим коммитом» НЕ ужесточён — менялась только реализация под капотом. **Защита ORCH-073 неприкосновенна и приоритетна:** подтверждение merge остаётся ТОЛЬКО `verify_merged_to_main` (SHA-в-main) + `check_main_regression`; `ensure_open_pr` устраняет лишь ЛОЖНЫЙ HOLD «no open PR», реально невлитый код → HOLD как прежде. Kill-switch `ORCH_MERGE_VERIFY_AUTOCREATE_PR_ENABLED` (дефолт `true`); область — `merge_verify_applies(repo)` (self-hosting / `merge_verify_repos`), non-self → no-op; `false` → поведение ORCH-074 1:1. Идемпотентность из Gitea (наличие открытого PR), без миграции БД (restart-safe); `main` не push/force-push. Инварианты НЕ менялись: `STAGE_TRANSITIONS`, реестр `QG_CHECKS` (под-гейт — врезка в `advance_stage`, не новый QG), схема БД, `check_deploy_status`/`_parse_deploy_status`, exit-коды хука, merge-gate (ORCH-043), image-freshness (ORCH-058), внешние HTTP-эндпоинты. ADR `docs/work-items/ORCH-082/06-adr/ADR-001-ensure-open-pr-before-merge-verify.md` (+ сквозной `adr-0016`). Документация: `docs/architecture/README.md` (блок ORCH-082 в merge-verify). Тесты: `tests/test_orch082_ensure_pr.py` (TC-01..05: идемпотентный актор, фильтр base==main, гонка 409/422, never-raise), `tests/test_orch082_merge_verify_autocreate.py` (TC-06..12: врезка, регресс ORCH-073, kill-switch, условность, наблюдаемость). diff --git a/CLAUDE.md b/CLAUDE.md index 75d809b..f375f01 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,16 +41,33 @@ created → analysis → architecture → development → review → testing → ## Статусная модель Plane (ORCH-066) — индикация ≠ управление Статусы Plane — это **слой B (индикация)**, отдельный от **слоя A (машина стадий)** `src/stages.py::STAGE_TRANSITIONS`. Plane показывает наблюдателю осмысленную картину (`Backlog → Todo → Analysis → Architecture → Development → Code-Review → Testing → Awaiting Deploy → Deploying → Monitoring after Deploy → Done` + человеческие гейты `In Review/Approved`, `Confirm Deploy`), но НИКОГДА не управляет конвейером. Маппинг и сеттеры — `src/plane_sync.py` (6 новых ключей: `to_analyse/analysis/code_review/awaiting_deploy/deploying/monitoring`), с project-relative alias-fallback: на частично сконфигурированном проекте новый ключ деградирует на базовый UUID ТОГО ЖЕ проекта (нулевая регрессия для enduro-trails). Детали — `docs/architecture/README.md`. -## Нотификации / Telegram live-tracker (ORCH-042/066/067) +## Нотификации / Telegram live-tracker (ORCH-042/066/067/087) Каждая задача = **одна карточка** в Telegram (`src/notifications.py`). Поведение карточки: - **Дефолт `tracker_mode` — `bump`** (ORCH-067; `edit` доступен через `ORCH_TRACKER_MODE=edit`). `bump` на каждом обновлении удаляет старую карточку и шлёт свежую вниз чата (тихо), `edit` редактирует на месте. Инвариант «одна карточка на задачу» — в обоих режимах. +- **Зачистка сирот (ORCH-087):** bump ведёт авторитетный леджер ВСЕХ созданных карточек + (таблица `tracker_messages`, `deleted_at IS NULL` = жива) и на каждом обновлении удаляет + ВСЕ незакрытые mid, а не только скаляр `tracker_message_id` (он сохранён как указатель на + текущую карточку, BC). Это устраняет класс «замёрзшая сирота» (старая карточка с заголовком + ранней стадии, потерявшая ссылку при гонке/`delete`-fail+`send`-ok). Новый mid пишется в + леджер ТОЛЬКО при успешном `send` (BR-6); transient-`delete` остаётся незакрытым для ретрая; + «already gone»/>48ч (`_DELETE_GONE_MARKERS`) → закрывается. Остаточная гонка самозалечивается + за один bump. Known-limitation: Telegram 48ч (сироты старше неудаляемы). +- **Эффорт в строке стадии (ORCH-087):** колонка `agent_runs.effort` стампится фактическим + `resolve_agent_effort` в `launcher._spawn` (CLI его в result-JSON не возвращает); строка + рендерится `· {model} · {effort}` (developer=`xhigh`, tester/deployer=`medium`, прочие=`high`); + пустой/исторический effort → суффикс опускается. +- **Честное итоговое время (ORCH-087):** done-строка = три независимых подписанных метрики + `⏱️ Агенты {Σ agent_runs} · твоё {review~cap} · общее с ожиданием {wall}` (раньше `Всего {wall}` + читалось как сумма, которой не является). «Твоё» ограничено `tracker_brd_review_cap_s` + (`ORCH_TRACKER_BRD_REVIEW_CAP_S`, дефолт 2ч; маркер `~` при отсечке аномального застоя). - **Статус-строка карточки** (`📍 `) показывает текущий Plane-статус по модели ORCH-066 (`plane_status_label`). Оффлайн-ядро (`stage → статус`, In Review из brd-clock) работает всегда без сети; best-effort live-overlay (kill-switch `tracker_live_status`, TTL-кэш, короткий таймаут) лишь дорисовывает ветки, неотличимые offline (Needs Input / - Blocked / Rejected / Cancelled / Deploying / Monitoring) и **никогда не блокирует конвейер**. + Blocked / Rejected / Cancelled / **Confirm Deploy** / Deploying / Monitoring) и **никогда не + блокирует конвейер**. - **Кликабельный номер задачи** (`plane_issue_link`) — `ORCH-NNN` в карточке И во всех уведомлениях (`notify_*`, alert'ы стадий) рендерится как `` на issue в Plane; fail-safe → просто `html.escape(номер)`, если ссылку построить нельзя. Никогда не падает. diff --git a/README.md b/README.md index 4036e94..8a1adce 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,7 @@ uvicorn src.main:app --reload --port 8500 | `ORCH_REPOS_DIR` | Repos dir (container) | `/repos` | | `ORCH_HOST_REPOS_DIR` | Repos dir (host) | `/home/slin/repos` | | `ORCH_DB_PATH` | SQLite path | `/app/data/orchestrator.db` | +| `ORCH_RUNS_DIR` | Базовый каталог per-run логов агентов (`/{run_id}.log`, ORCH-087) | `/app/data/runs` | | `ORCH_MAX_CONCURRENCY` | Сколько jobs воркер запускает параллельно (ORCH-1) | `1` | | `ORCH_QUEUE_POLL_INTERVAL` | Период опроса очереди воркером, сек (ORCH-1) | `2.0` | | `ORCH_PREFLIGHT_CACHE_TTL` | Кэш preflight (CLI/net), сек (ORCH-1 resilience) | `45` | diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 924de80..fe43914 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -13,7 +13,7 @@ - **Queue** (`src/queue_worker.py`, ORCH-1) — персистентная очередь задач (SQLite `jobs`), atomic claim, max_concurrency, ретраи, restart-safe. **ORCH-026:** `claim_next_job` гейтит задачи с незавершёнными зависимостями (`job_deps`, `NOT EXISTS`) без занятия слота; декларации/циклы — leaf `src/task_deps.py`. - **Job-reaper** (`src/job_reaper.py`, ORCH-065 — [adr-0011](adr/adr-0011-job-reaper-lease-reclaim.md)) — фоновый daemon-поток (каркас `reconciler`), стартует/останавливается в `main.lifespan` (после `reconciler.start()` / перед `worker.stop()`). Детектирует «мёртвый» `running`-job **без рестарта** процесса (Tier-1 мёртвый `jobs.pid` после `reaper_dead_ticks` тиков; Tier-2 `agent_runs.exit_code` записан, а job ещё `running`; Tier-3 backstop `reaper_max_running_s`) и приводит строку к корректному статусу через те же контракты (`_try_advance_stage`/`_finalize_job`, gate-driven; exit≠0/неизвестно → `attempts1. +2. **В какие МОМЕНТЫ `tracker_message_id` рассинхронизируется** с реальными сообщениями: + - (a) `send` вернул `None` (нет креды / transient) → `mid` не перезаписан; + - (b) рестарт орка между `delete` и `send`; + - (c) пересоздание карточки во время CLI-фикса / ручных операций; + - (d) гонка двух `update_task_tracker` подряд (быстрые стадии); + - (e) `delete` упал (rate-limit / >48ч), но `send` прошёл. +3. **Почему ИМЕННО заголовок застывает на `To Analyse`:** это старый рендер (до смены stage) или баг плана-лейбла? Воспроизвести на staging: прогнать задачу, на каждой стадии зафиксировать что РЕАЛЬНО в Telegram (заголовок+тело) vs что в БД (`stage`). +4. **`bump` vs `edit`:** какой режим реально надёжнее против сирот — замерить, а не предполагать. `edit` правит ОДНО сообщение in-place (нет сирот, но не держит карточку внизу); `bump` держит внизу (фича-просьба ORCH-042), но плодит сирот при рассинхроне. Дать обоснованную рекомендацию с данными. + +## 5. Не-цели + +- Не плодить дубликаты — инвариант «одна карточка на задачу» сохранить. +- Не пинговать — `disable_notification` остаётся (карточка тихая). +- Не ломать ссылки ORCH-067 (`plane_issue_link`, кликабельный номер) и `disable_web_page_preview` (ORCH-080). +- Не вводить новую стадию конвейера / не менять `STAGE_TRANSITIONS` / `QG_CHECKS`. +- Не предрешать `bump` vs `edit` в BRD — это вывод G0/ADR. + +## 6. Ограничения и грабли + +- Telegram не даёт удалять сообщения **старше 48ч** — для совсем старых сирот зачистка может не сработать. Документировать как ограничение (`delete_telegram` уже классифицирует это как «gone»/не-transient). +- Эффорт **не возвращается** Claude CLI в result-JSON (в отличие от модели, которая берётся из `modelUsage`). Поэтому надёжный источник эффорта — стамп резолва (`resolve_agent_effort`) В МОМЕНТ запуска, а не пересчёт постфактум. +- Контракт всего компонента нотификаций — **never raises**; карточка всегда silent. +- Self-hosting: задача правит инструмент, работающий в проде и обслуживающий enduro-trails. НЕ ронять прод-контейнер; обязательная страховка — `deploy-staging` (8501). + +## 7. Бизнес-ценность + +Наблюдатель (Слава) видит ровно одну достоверную карточку: текущий статус, эффорт каждой стадии и честное время. Уходит класс багов «замёрзшая сирота вводит в заблуждение» и «магическое раздутое итоговое время». diff --git a/docs/work-items/ORCH-087/02-trz.md b/docs/work-items/ORCH-087/02-trz.md new file mode 100644 index 0000000..a2a9e43 --- /dev/null +++ b/docs/work-items/ORCH-087/02-trz.md @@ -0,0 +1,117 @@ +# ТЗ — ORCH-087 + +Техническое задание для архитектора/разработчика. Конкретные изменения кода/БД с привязкой к BR (см. `01-brd.md`). Архитектурные РЕШЕНИЯ (выбор механизма зачистки сирот, выбор `bump`/`edit`, формула отсечки аномалий времени) принимает архитектор в ADR на основе G0 — здесь зафиксированы требования и точки врезки. + +--- + +## 0. Задействованные модули `src/` + +| Модуль | Роль в задаче | +|--------|---------------| +| `src/notifications.py` | `update_task_tracker` (bump/edit), `render_task_tracker`, `_stage_line`, итоговая строка времени, `plane_status_label`/`_card_status_label` (заголовок/deploy-цикл) | +| `src/db.py` | учёт `message_id` карточек задачи (BR-G1); колонка `agent_runs.effort` (BR-EFF); геттеры/сеттеры | +| `src/agents/launcher.py` | `_spawn`: стамп `resolve_agent_effort(agent)` в `agent_runs.effort` в момент запуска (BR-EFF) | +| `src/usage.py` | `short_model_name` (рядом — рендер эффорта); при необходимости — пробрасывать effort в строку стадии | +| `tests/test_notifications*.py`, `tests/test_*tracker*` | unit-покрытие | + +**НЕ трогать** (BR-G6): `src/reconciler.py` / `tests/test_reconciler.py` — задача не требует их правок; пересечение с ORCH-86 неприемлемо. Если правка всё же понадобится — сохранить ORCH-086 (`skipped_terminal_total`, `state_uuid`-dedup, terminal-skip F-1) и явно проверить на merge-gate. + +## 1. G0 — расследование (BR-G0) → ADR + +- Исследование выполняется ДО кода: собрать факты по §4 BRD (логи орка `data/runs`, Telegram message_id, БД `tracker_message_id`/`stage` по ORCH-082), воспроизвести прогон на staging (8501), зафиксировать таблицу «стадия → (заголовок+тело в Telegram) vs (stage в БД)». +- Артефакт расследования и обоснованная рекомендация `bump` vs `edit` → `06-adr/ADR-NNN-tracker-orphan-cleanup.md`. +- Код фикса (G1–G3) реализует выбранный в ADR механизм. ТЗ ниже задаёт ИНВАРИАНТЫ, которым любой выбранный механизм обязан удовлетворять. + +## 2. G1 — гарантированная зачистка сирот (BR-G1) + +**Требование-инвариант:** после любого `update_task_tracker` в чате не остаётся НИ ОДНОЙ ранее созданной карточки этой задачи, кроме текущей (в пределах 48ч-лимита Telegram). + +Точка проблемы (текущий код, `update_task_tracker`, ветка `mode == "bump"`): +```python +if mid is not None: + delete_telegram(mid) # удаляется ТОЛЬКО последний mid +new_mid = send_telegram(text, disable_notification=True) +if new_mid is not None: + set_tracker_message_id(task_id, new_mid) +``` +`tasks.tracker_message_id` — скаляр (последний `mid`). При рассинхроне (send→None / рестарт между delete и send / пересоздание / гонка / delete-fail+send-ok) прежние карточки теряют ссылку и осиротевают. + +**Требования к решению (любой механизм из ADR):** +- R-1. Система должна знать обо ВСЕХ незакрытых `message_id` карточек задачи (а не только о последнем), чтобы подчищать их при следующем bump / на рассинхроне / при старте. +- R-2. Перед/в момент создания новой карточки удаляются ВСЕ известные незакрытые `message_id`; успешно удалённые (включая «already gone» по `_DELETE_GONE_MARKERS`) исключаются из учёта; не удалённые transient — остаются в учёте для повторной попытки. +- R-3. Новый `message_id` записывается в учёт ТОЛЬКО при успешном `send` (`new_mid is not None`) — transient send не должен обнулять/терять учёт (сохранить текущую защиту BR-6). +- R-4. Инвариант «одна карточка на задачу» и «не более одного `send` за вызов» сохраняются → дубликатов внутри вызова нет. +- R-5. **Кандидатные механизмы для ADR** (выбор за архитектором, не предрешать в коде до ADR): + - (A) bump + полный учёт `message_id` (новая таблица `tracker_messages(task_id, message_id, created_at, deleted_at)` ИЛИ JSON-массив в колонке `tasks.tracker_message_ids`), зачистка всех незакрытых; + - (B) переход дефолта на `edit` (нет сирот by design; теряется «карточка внизу» ORCH-042) — взвесить против фича-просьбы. +- R-6. Изменение схемы БД (если выбран вариант A) — строго аддитивное (`CREATE TABLE IF NOT EXISTS` / `_ensure_column`), идемпотентное, restart-safe на живой общей прод-БД (данные enduro не трогаются). Детали данных — `08-data-requirements.md`. + +## 3. G2 — заголовок отражает текущую стадию (BR-G2) + +- Рендер `render_task_tracker` уже строит заголовок/статус-строку из `tasks.stage` (`plane_status_label` → `_card_status_label`). Замёрзший `To Analyse` — следствие осиротевшей карточки (G1), а не бага рендера. +- Требование: после фикса G1 единственная живая карточка всегда несёт заголовок текущей стадии. Регресс-тест: на каждой стадии заголовок/статус-строка соответствуют `stage` в БД (часть staging-воспроизведения G0 + unit на `plane_status_label`). + +## 4. G3 — deploy-цикл на карточке (BR-G3) + +- Проверить, что `_STAGE_STATUS_LABEL["deploy"]` (`⏸️ Awaiting Deploy — ожидание Confirm Deploy`) + live-overlay `_live_plane_branch_override` (`deploying`, `monitoring`) покрывают весь цикл `Awaiting Deploy → Deploying → Confirm Deploy → Monitoring → Done`. +- Если какой-то под-статус не отображается на соответствующей стадии — добить offline-label/overlay. `Done` рендерится из `stage == "done"`. Контракт never-raise и kill-switch `tracker_live_status` сохраняются. + +## 5. BR-EFF — эффорт в строке стадии + +**API/данные:** +- Новая колонка `agent_runs.effort TEXT` (миграция `_ensure_column(conn, "agent_runs", "effort", "TEXT")` в `src/db.py`, рядом с `model`). +- **Стамп в момент запуска** (`launcher._spawn`): сразу после резолва `effort = resolve_agent_effort(agent, project_id)` записать его в строку `agent_runs` (тот же `run_id`). Источник — РЕАЛЬНО ушедшее в `--effort` значение (`""`/без флага → сохранить пусто/`NULL`). Это надёжнее пересчёта (CLI не возвращает эффорт в result-JSON). + - Допустимо: расширить `INSERT INTO agent_runs (task_id, agent, effort) VALUES (?,?,?)` или отдельным `UPDATE agent_runs SET effort=? WHERE id=?` после резолва. Выбор — архитектор; значение должно соответствовать фактическому флагу запуска. + +**Рендер** (`render_task_tracker._stage_line`): +- Текущий суффикс: `f" · {model}"` при наличии модели. +- Добавить эффорт рядом: формат `· opus-4-8 · xhigh` ИЛИ компактно `· opus-4-8/xhigh` (на усмотрение, выбрать единый). При пустом эффорте — суффикс эффорта опускается (как опускается модель при пустой `short_model_name`). +- Брать `effort` из строки `agent_runs` соответствующей стадии (последний завершённый run, как `model`). Допустим fallback на `resolve_agent_effort(agent)` для исторических строк без колонки. + +**Ожидаемо:** developer-строка → `xhigh`; tester/deployer → `medium`; analyst/architect/reviewer → `high` (по таблице ORCH-41/081). + +## 6. BR-G5 — честное и сходимое итоговое время + +Текущая итоговая строка (`done`): +```python +wall = _duration_seconds(created_at, updated_at) # раздут: вся очередь+ожидание+застой +review_seconds = _duration_seconds(brd_review_started, brd_review_ended) # раздут при застое +"⏱️ Всего {wall} · агенты {agent_seconds} · твоё {review}" +``` +Проблема: `wall ≠ agent_seconds + review_seconds` (незалогированные queue-паузы) → итог визуально «врёт»; `review_seconds` засчитывает застой/рассинхрон (ORCH-087: 392м). + +**Требования (формула — за архитектором, G5 «КАК — архитектору»):** +- T-1. Чистое рабочее время агентов = `Σ _duration_seconds(started, finished)` по `agent_runs` (текущий `agent_seconds`) — **главная метрика**, оставить точной. +- T-2. Человеческое BRD-время — ТОЛЬКО фактическое: НЕ включать аномальный застой/рассинхрон (`brd_review` болтался открытым из-за рассинхрона In Review→Backlog). Ограничить разумным порогом ИЛИ считать только активные окна. Аномалия не должна показываться как «твоё время». +- T-3. Wall-clock — если показываем, помечать как «общее (с ожиданием)», НЕ выдавать за рабочее время. +- T-4. Итог должен СХОДИТЬСЯ: либо `wall = Σ(стадии) + Σ(паузы с подписью)`, либо не показывать wall как сумму. Прозрачность вместо «магического» числа. +- T-5. `agent_runs`-агрегация (`total_in/total_out/total_cost/agent_seconds`) и `💰`-строка — без регресса. + +## 7. Изменения API (endpoints) + +Нет новых/изменённых HTTP-endpoint. (Опционально — отразить учёт карточек/effort в read-only снимке `GET /queue`, если архитектор сочтёт нужным; не обязательно.) + +## 8. Изменения схемы БД + +- `agent_runs.effort TEXT` — аддитивно, идемпотентно (`_ensure_column`). **Обязательно.** +- Учёт `message_id` (BR-G1, если выбран вариант A) — аддитивная таблица `tracker_messages` ИЛИ колонка-массив `tasks.tracker_message_ids`. **Зависит от ADR.** Подробности — `08-data-requirements.md`. +- Существующие колонки/таблицы (`tasks.tracker_message_id`, `brd_review_*`, `agent_runs.model`) — не ломать; при варианте A сохранить обратную совместимость со скалярным `tracker_message_id` (миграция/со-существование). + +## 9. Требования к новым QG-проверкам + +Нет. `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, машинные вердикты гейтов — без изменений. + +## 10. Артефакты pipeline, создаваемые/обновляемые + +- `06-adr/ADR-NNN-tracker-orphan-cleanup.md` (G0 вывод + рекомендация bump/edit + механизм G1 + формула G5) — архитектор. +- Обновить `CLAUDE.md` (§ Нотификации) и `docs/architecture/README.md` (компонент Notifications) — отразить учёт карточек, эффорт-в-строке, честное время. **Golden source наравне с кодом.** +- `CHANGELOG.md` — `## [Unreleased]` запись (под `.gitattributes merge=union`). + +## 11. Инварианты (не нарушать) + +- never-raise во всём пути нотификаций; карточка всегда silent (`disable_notification`). +- «одна карточка на задачу»; ≤1 `send` за вызов `update_task_tracker`. +- Ссылки ORCH-067 (`plane_issue_link`), `disable_web_page_preview` ORCH-080 — сохранены. +- `STAGE_TRANSITIONS` / `QG_CHECKS` / стадии конвейера — без изменений. +- БР-G6: разработка/merge поверх свежего `origin/main` (ORCH-86); `reconciler.py` не эродировать. +- Миграции БД аддитивны и идемпотентны (общая прод-БД, enduro не трогать). diff --git a/docs/work-items/ORCH-087/03-acceptance-criteria.md b/docs/work-items/ORCH-087/03-acceptance-criteria.md new file mode 100644 index 0000000..80d68c2 --- /dev/null +++ b/docs/work-items/ORCH-087/03-acceptance-criteria.md @@ -0,0 +1,71 @@ +# Критерии приёмки — ORCH-087 + +Каждый критерий — чёткое условие PASS/FAIL. Привязка к BR (`01-brd.md`) и ТЗ (`02-trz.md`). + +--- + +## G0 — расследование + +| ID | Критерий | PASS | FAIL | +|----|----------|------|------| +| AC-0.1 | ADR `06-adr/ADR-NNN-tracker-orphan-cleanup.md` существует и отвечает на ВСЕ 4 вопроса §4 BRD (число реальных сирот; точки рассинхрона a–e; причина застывания `To Analyse`; bump vs edit с данными). | ADR содержит ответы по всем 4 пунктам + явную рекомендацию | Любой вопрос без ответа / рекомендация без обоснования | +| AC-0.2 | В ADR зафиксировано staging-воспроизведение: таблица «стадия → (заголовок+тело в Telegram) vs (stage в БД)» по прогону задачи на 8501. | Таблица воспроизведения приложена | Воспроизведения нет / только предположения | +| AC-0.3 | Фикс (G1–G3) реализует механизм, выбранный и обоснованный в ADR (не противоречит выводу). | Код соответствует ADR | Код расходится с ADR без объяснения | + +## G1 — нет осиротевших карточек + +| ID | Критерий | PASS | FAIL | +|----|----------|------|------| +| AC-1.1 (=AC-1) | После прохождения стадий в чате НЕ остаётся карточек с устаревшим заголовком (нет `To Analyse` на завершённой задаче). | На staging-прогоне в чате только одна карточка, заголовок актуальный | Видна ≥1 замёрзшая/устаревшая карточка | +| AC-1.2 | Система ведёт учёт ВСЕХ незакрытых `message_id` задачи (не только последнего); при bump удаляются ВСЕ известные незакрытые. | Учёт присутствует, unit-тест на мульти-mid зачистку зелёный | Учёт только скаляр / сироты остаются | +| AC-1.3 (=AC-3) | При сбое `send` (`new_mid=None`) / рестарте орка / гонке указатель не теряет старые карточки — они подчищаются (или остаются в учёте до следующей попытки). | Unit моделирует send→None / повторный вызов: прежние mid не потеряны | mid теряется → сирота | +| AC-1.4 | Telegram-лимит 48ч на удаление задокументирован как known-limitation (старые сироты могут не удалиться). | Ограничение в ADR/доке | Не упомянуто | + +## G2 — актуальный заголовок + +| ID | Критерий | PASS | FAIL | +|----|----------|------|------| +| AC-2.1 (=AC-2) | Единственная актуальная карточка показывает текущий статус, включая весь deploy-цикл. | На каждой стадии заголовок/статус соответствует `stage` в БД | Расхождение заголовка и `stage` | +| AC-2.2 | `plane_status_label(stage)` детерминированно даёт корректный лейбл для всех стадий `created…done` (unit). | Unit перебирает все стадии, лейблы верны | Любой stage даёт неверный/`To Analyse` по умолчанию некорректно | + +## G3 — deploy-цикл виден + +| ID | Критерий | PASS | FAIL | +|----|----------|------|------| +| AC-3.1 | Стадия `deploy` показывает `⏸️ Awaiting Deploy` (offline). | Unit/staging подтверждает | Не показывает | +| AC-3.2 | Live-overlay покрывает `Deploying` / `Monitoring` (когда Plane-статус реально такой). | Overlay рисует ветку при наличии UUID статуса | Ветка не рисуется при живом статусе | +| AC-3.3 | `Done` рендерится по `stage == "done"` (`ГОТОВО` + итог). | Карточка done корректна | — | + +## BR-EFF — эффорт в карточке + +| ID | Критерий | PASS | FAIL | +|----|----------|------|------| +| AC-E.1 | Колонка `agent_runs.effort` создаётся идемпотентно; стамп фактического эффорта происходит в момент запуска агента. | Миграция + стамп есть, unit подтверждает запись | Колонки нет / эффорт не стампится | +| AC-E.2 | Строка каждой завершённой стадии карточки показывает эффорт рядом с моделью (выбранный формат `· model · effort` или `· model/effort`). | Рендер содержит эффорт, unit зелёный | Эффорт отсутствует в строке | +| AC-E.3 | developer-строка показывает `xhigh`; tester/deployer — `medium`; analyst/architect/reviewer — `high`. | Значения соответствуют ORCH-41/081 | Значения не совпадают | +| AC-E.4 | Пустой/неизвестный эффорт → суффикс эффорта опускается, рендер не падает. | Unit на пустой effort зелёный | Падение/мусорный суффикс | + +## BR-G5 — честное время + +| ID | Критерий | PASS | FAIL | +|----|----------|------|------| +| AC-5.1 | На задаче с искусственным застоем (открытый `brd_review` ~6ч) итоговое «твоё время» НЕ показывает ~6ч. | Unit с brd-окном 6ч → «твоё время» ограничено/активное, не 6ч | Показывает ~6ч | +| AC-5.2 | agent-время = `Σ agent_runs` точно (без регресса). | Unit сверяет сумму | Расхождение | +| AC-5.3 | Числа в итоговой строке сходятся: wall помечен как «общее (с ожиданием)» ИЛИ wall = Σ(стадии)+Σ(паузы с подписью). | Итог прозрачен и согласован | wall выдаётся за рабочее/не сходится | + +## BR-G6 — свежий main / без эрозии reconciler + +| ID | Критерий | PASS | FAIL | +|----|----------|------|------| +| AC-6.1 | Ветка разработана/смержена поверх `origin/main`, содержащего ORCH-86 (`merge-base` = merge-коммит 86 или новее). | `git merge-base --is-ancestor origin/main HEAD` → true; маркеры ORCH-086 в `src/reconciler.py` ветки присутствуют | Ветка отстаёт / маркеры 86 потеряны | +| AC-6.2 | `src/reconciler.py` / `tests/test_reconciler.py` не эродированы (ORCH-086 terminal-skip + `state_uuid`-dedup на месте). Проверено на merge-gate. | Диф не удаляет ORCH-086 логику; merge-gate зелёный | Логика 86 затёрта | + +## Сквозные + +| ID | Критерий | PASS | FAIL | +|----|----------|------|------| +| AC-X.1 (=AC-4) | Инвариант «одна карточка на задачу» соблюдён; дубликатов нет; ≤1 `send` за вызов. | Unit/staging: одна карточка | Дубликаты | +| AC-X.2 (=AC-5 задачи) | `pytest tests/ -q` зелёный; весь путь нотификаций never-raise (любая ошибка Telegram/БД не валит конвейер). | Тесты зелёные; unit на исключения не поднимает | Падение/raise | +| AC-X.3 | Документация обновлена в ТОМ ЖЕ PR: `CLAUDE.md` (§Нотификации), `docs/architecture/README.md` (Notifications), `CHANGELOG.md`. | Доки обновлены | Reviewer → REQUEST_CHANGES | +| AC-X.4 | Ссылки ORCH-067 (`plane_issue_link`) и `disable_web_page_preview` (ORCH-080) сохранены. | Кликабельный номер + нет link-preview | Регресс | +| AC-X.5 | `STAGE_TRANSITIONS` / `QG_CHECKS` без изменений; миграции БД аддитивны/идемпотентны (enduro-данные не тронуты). | Диф не меняет машину стадий; миграции безопасны | Изменение машины стадий / небезопасная миграция | diff --git a/docs/work-items/ORCH-087/04-test-plan.yaml b/docs/work-items/ORCH-087/04-test-plan.yaml new file mode 100644 index 0000000..d7cfdb9 --- /dev/null +++ b/docs/work-items/ORCH-087/04-test-plan.yaml @@ -0,0 +1,115 @@ +work_item: ORCH-087 +description: > + Тест-план для багфикса live-трекера (сироты/заголовок/deploy-цикл), + эффорта-в-карточке, честного итогового времени. Юнит-тесты — pytest, + изоляция Telegram через monkeypatch (send/edit/delete не ходят в сеть). + Интеграция/воспроизведение — на staging (8501). Контракт never-raise + проверяется отдельными negative-тестами. + +tests: + # ---------------- G1: зачистка сирот ---------------- + - id: TC-01 + type: unit + description: "bump удаляет ВСЕ известные незакрытые message_id задачи, не только последний (мок delete/send)" + module: tests/test_notifications_orphans.py + expected: PASS + - id: TC-02 + type: unit + description: "send вернул None (нет креды/transient) → учёт прежних message_id не теряется, mid не обнуляется (BR-6 + R-3)" + module: tests/test_notifications_orphans.py + expected: PASS + - id: TC-03 + type: unit + description: "delete вернул False (transient, >48ч) → message_id остаётся в учёте для повторной попытки; 'already gone' (_DELETE_GONE_MARKERS) → исключается из учёта" + module: tests/test_notifications_orphans.py + expected: PASS + - id: TC-04 + type: unit + description: "повторные вызовы update_task_tracker подряд (быстрые стадии/гонка) → ровно одна живая карточка, ≤1 send за вызов, без дублей (AC-X.1)" + module: tests/test_notifications_orphans.py + expected: PASS + - id: TC-05 + type: unit + description: "учёт message_id переживает 'рестарт' (читается из БД) → старые карточки подчищаются при следующем bump (AC-1.3)" + module: tests/test_notifications_orphans.py + expected: PASS + + # ---------------- G2/G3: заголовок и deploy-цикл ---------------- + - id: TC-06 + type: unit + description: "plane_status_label детерминированно даёт корректный лейбл для всех stage created..done; deploy → 'Awaiting Deploy' (AC-2.2, AC-3.1)" + module: tests/test_notifications.py + expected: PASS + - id: TC-07 + type: unit + description: "render_task_tracker: заголовок/статус-строка соответствуют tasks.stage на каждой стадии (нет застывшего To Analyse) (AC-2.1)" + module: tests/test_notifications.py + expected: PASS + - id: TC-08 + type: unit + description: "live-overlay рисует Deploying/Monitoring при наличии соответствующего Plane-UUID; деградирует на offline-label при ошибке/выкл. kill-switch (AC-3.2)" + module: tests/test_notifications.py + expected: PASS + + # ---------------- BR-EFF: эффорт в карточке ---------------- + - id: TC-09 + type: unit + description: "миграция agent_runs.effort идемпотентна (_ensure_column дважды — без ошибки) (AC-E.1)" + module: tests/test_db.py + expected: PASS + - id: TC-10 + type: unit + description: "launcher стампит resolve_agent_effort(agent) в agent_runs.effort в момент запуска; значение = фактический --effort (AC-E.1)" + module: tests/test_launcher.py + expected: PASS + - id: TC-11 + type: unit + description: "строка стадии рендерит эффорт рядом с моделью в выбранном формате; developer=xhigh, tester/deployer=medium, прочие=high (AC-E.2, AC-E.3)" + module: tests/test_notifications.py + expected: PASS + - id: TC-12 + type: unit + description: "пустой/неизвестный effort → суффикс эффорта опускается, рендер не падает (AC-E.4)" + module: tests/test_notifications.py + expected: PASS + + # ---------------- BR-G5: честное время ---------------- + - id: TC-13 + type: unit + description: "brd_review-окно ~6ч (искусственный застой) → итоговое 'твоё время' НЕ показывает ~6ч (отсечка/активные окна) (AC-5.1)" + module: tests/test_notifications.py + expected: PASS + - id: TC-14 + type: unit + description: "agent-время = Σ _duration_seconds(agent_runs) точно; 💰-итоги без регресса (AC-5.2)" + module: tests/test_notifications.py + expected: PASS + - id: TC-15 + type: unit + description: "итоговая строка done: wall помечен как 'общее (с ожиданием)' ИЛИ wall сходится с Σ(стадии)+Σ(паузы); числа согласованы (AC-5.3)" + module: tests/test_notifications.py + expected: PASS + + # ---------------- never-raise / сквозные ---------------- + - id: TC-16 + type: unit + description: "update_task_tracker / render_task_tracker никогда не поднимают исключение при ошибке Telegram/БД (моки бросают) (AC-X.2)" + module: tests/test_notifications.py + expected: PASS + - id: TC-17 + type: unit + description: "ссылки ORCH-067 (plane_issue_link кликабельный номер) и disable_web_page_preview (ORCH-080) сохранены в payload (AC-X.4)" + module: tests/test_notifications.py + expected: PASS + + # ---------------- интеграция / воспроизведение ---------------- + - id: TC-18 + type: integration + description: "staging-прогон задачи (8501): на каждой стадии зафиксировать (заголовок+тело в Telegram) vs (stage в БД); в чате остаётся одна актуальная карточка без сирот (G0 воспроизведение, AC-0.2, AC-1.1)" + module: docs/work-items/ORCH-087/06-adr # фиксируется в ADR как таблица воспроизведения + expected: PASS + - id: TC-19 + type: integration + description: "merge-gate: ветка поверх origin/main с ORCH-86; reconciler.py не эродирован (маркеры ORCH-086 на месте), pytest tests/ -q зелёный (AC-6.1, AC-6.2, AC-X.2)" + module: tests/ + expected: PASS 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). diff --git a/docs/work-items/ORCH-087/12-review.md b/docs/work-items/ORCH-087/12-review.md new file mode 100644 index 0000000..20aa34b --- /dev/null +++ b/docs/work-items/ORCH-087/12-review.md @@ -0,0 +1,72 @@ +--- +type: review +work_item_id: ORCH-087 +verdict: APPROVED +version: 1 +--- + +# Review ORCH-087 + +## Summary +Задача закрывает три проблемы live-трекера: (G1) осиротевшие «замёрзшие» карточки, +(BR-EFF) эффорт в строке стадии, (BR-G5) честное итоговое время, плюс попутный CI-фикс +пути per-run логов. Реализация соответствует ТЗ, ADR-001 и критериям приёмки. Все 1090 +тестов зелёные. Документация (CLAUDE.md, README.md, docs/architecture/README.md, +CHANGELOG.md, ADR) обновлена в том же PR. Машина стадий и реестр QG не тронуты; миграции +аддитивны/идемпотентны; never-raise сохранён. Найдена одна косметика P3 (неточный +inline-комментарий), не влияющая на поведение. Блокеров нет. + +## Соответствие ТЗ / ADR + +- **G1 (BR-G1, AC-1.x):** аддитивный леджер `tracker_messages(task_id, message_id, + created_at, deleted_at)` + хелперы `add_tracker_message` / `get_open_tracker_messages` / + `mark_tracker_message_deleted` (`src/db.py`). На каждом bump зачищаются ВСЕ незакрытые + mid (union скаляр+леджер). Контракт `delete_telegram` (True=gone вкл. `_DELETE_GONE_MARKERS`, + False=transient) совпадает с логикой `if delete_telegram(old): mark_deleted(...)`; + transient остаётся открытым для ретрая. Новый mid в леджер ТОЛЬКО при `send is not None` + (R-3/BR-6). Скаляр `tracker_message_id` сохранён (BC). ✔ соответствует ADR §G1 (вариант A1). +- **G3 (AC-3.x):** ключ `confirm_deploy` добавлен в `_LIVE_BRANCH_LABELS` — цикл + `Awaiting Deploy → Deploying → Confirm Deploy → Monitoring → Done` полон. ✔ +- **BR-EFF (AC-E.x):** колонка `agent_runs.effort TEXT` (`_ensure_column`, идемпотентно); + стамп фактического `resolve_agent_effort` в `launcher._spawn` через `UPDATE` по `run_id` + (never-block, обёрнут try/except); рендер `· {model} · {effort}`, пустой → опускается. ✔ +- **BR-G5 (AC-5.x):** done-строка переписана на три подписанных метрики + `⏱️ Агенты · твоё{~cap} · общее с ожиданием`; кап `tracker_brd_review_cap_s` (дефолт 2ч, + маркер `~`); `_capped_review_str` never-raise; agent-сумма не регрессировала. ✔ +- **BR-G6 (AC-6.x):** `src/reconciler.py` / `tests/test_reconciler.py` НЕ тронуты; + `git merge-base --is-ancestor origin/main HEAD` → true; origin/main содержит merge ORCH-086; + маркеры ORCH-086 (`skipped_terminal_total`/`state_uuid`/terminal) на месте. ✔ +- **Инварианты (AC-X.5):** `STAGE_TRANSITIONS` / `QG_CHECKS` без изменений; миграции + `CREATE TABLE/INDEX IF NOT EXISTS` + `_ensure_column` — аддитивны/идемпотентны (enduro не + трогается); `disable_notification` / `plane_issue_link` / `disable_web_page_preview` — + сохранены. ✔ +- **ADR (AC-0.x):** ADR-001 отвечает на 4 вопроса §4 BRD, содержит таблицу staging- + воспроизведения и known-limitation Telegram 48ч (AC-1.4). Код фикса соответствует ADR. ✔ + +## Findings + +### P0 — Blocker +- (нет) + +### P1 — Must fix +- (нет) + +### P2 — Should fix +- (нет) + +### P3 — Nice to have +- [ ] `src/notifications.py` (~стр. 460, докстринг блока рендера эффорта): комментарий + утверждает «Historical rows with NULL effort fall back to the config-resolved effort for + the agent», но `_run_effort` фолбэка на `resolve_agent_effort` НЕ делает — при пустом/NULL + effort возвращает `""` и суффикс опускается. Поведение корректно и соответствует AC-E.4 + (fallback по ТЗ §5 был «Допустим», не обязателен); неточен лишь комментарий — стоит убрать + вводящую в заблуждение фразу или реально добавить фолбэк. Не влияет на работу. + +## Документация +Обновлена в ТОМ ЖЕ PR (AC-X.3 выполнен): +- `CLAUDE.md` — §Нотификации/Telegram live-tracker (зачистка сирот, эффорт, честное время). +- `docs/architecture/README.md` — компонент Notifications + отдельный раздел ORCH-087. +- `README.md` — таблица env (`ORCH_RUNS_DIR`). +- `CHANGELOG.md` — `## [Unreleased]` запись (ORCH-087 трекер + CI-фикс пути логов). +- ADR `06-adr/ADR-001-tracker-orphan-cleanup.md` — присутствует, покрывает G0/механизм/формулу. +Замечаний по документации нет. diff --git a/docs/work-items/ORCH-087/13-test-report.md b/docs/work-items/ORCH-087/13-test-report.md new file mode 100644 index 0000000..b0af90c --- /dev/null +++ b/docs/work-items/ORCH-087/13-test-report.md @@ -0,0 +1,88 @@ +--- +type: test-report +work_item_id: ORCH-087 +result: PASS +--- + +# Test Report — ORCH-087 + +Багфикс live-трекера: зачистка осиротевших карточек (G1), эффорт в строке стадии +(BR-EFF), честное итоговое время (BR-G5), плюс deploy-цикл на карточке (G3). +Review-вердикт `12-review.md` — **APPROVED**. Прогнан полный регресс + smoke API. + +## Окружение +- Python: 3.12.13 +- pytest: 8.3.3 +- Ветка: `feature/ORCH-087-orch-87-to-analyse-bump` +- Репозиторий: orchestrator (worktree) +- Прод-инстанс (8500): health `ok` — деструктивных операций не выполнялось +- Дата: 2026-06-09 + +## Результаты — тест-план (04-test-plan.yaml) + +| TC ID | Описание | Тест(ы) | Результат | +|-------|----------|---------|-----------| +| TC-01 | bump удаляет ВСЕ незакрытые message_id, не только последний | `test_notifications_orphans.py` | PASS | +| TC-02 | send→None → учёт mid не теряется (BR-6/R-3) | `test_notifications_orphans.py` | PASS | +| TC-03 | delete=False (transient) остаётся в учёте; «already gone» исключается | `test_notifications_orphans.py` | PASS | +| TC-04 | повторные вызовы → одна живая карточка, ≤1 send, без дублей | `test_notifications_orphans.py` | PASS | +| TC-05 | учёт mid переживает «рестарт» (читается из БД) | `test_notifications_orphans.py` | PASS | +| TC-06 | plane_status_label детерминирован для created..done; deploy→Awaiting Deploy | `test_tracker_status_line.py` (parametrized) | PASS | +| TC-07 | заголовок/статус соответствуют tasks.stage (нет застывшего To Analyse) | `test_tracker_status_line.py` | PASS | +| TC-08 | live-overlay рисует Deploying/Monitoring; деградирует на offline при kill-switch | `test_tracker_status_line.py` | PASS | +| TC-09 | миграция agent_runs.effort идемпотентна (_ensure_column) | `test_launcher.py` / db-fallback тесты | PASS | +| TC-10 | launcher стампит resolve_agent_effort в agent_runs.effort при запуске | `test_launcher.py` (effort, 2 теста) | PASS | +| TC-11 | строка стадии рендерит эффорт рядом с моделью; dev=xhigh, tester/deployer=medium, прочие=high | `test_tracker_effort_time.py` | PASS | +| TC-12 | пустой/неизвестный effort → суффикс опускается, рендер не падает | `test_tracker_effort_time.py` | PASS | +| TC-13 | brd_review ~6ч (застой) → «твоё время» НЕ показывает ~6ч (cap) | `test_tracker_effort_time.py` | PASS | +| TC-14 | agent-время = Σ agent_runs точно; 💰-итоги без регресса | `test_tracker_effort_time.py` | PASS | +| TC-15 | done-строка: wall помечен «общее (с ожиданием)»; числа согласованы | `test_tracker_effort_time.py` | PASS | +| TC-16 | update_task_tracker/render никогда не raise при ошибке Telegram/БД | `test_tracker_status_line.py` / `test_notifications_orphans.py` | PASS | +| TC-17 | ссылки ORCH-067 (plane_issue_link) и disable_web_page_preview ORCH-080 сохранены | `test_tracker_issue_link.py` | PASS | +| TC-18 | staging-воспроизведение (G0): одна актуальная карточка без сирот | ADR-001 (таблица воспроизведения) | PASS (по ADR) | +| TC-19 | merge-gate: ветка поверх origin/main с ORCH-86; reconciler не эродирован; pytest зелёный | `git merge-base` + регресс | PASS | + +## Критерии приёмки (03-acceptance-criteria.md) +- **G0 (AC-0.x):** ADR-001 присутствует, отвечает на 4 вопроса §4 BRD, содержит таблицу + staging-воспроизведения и known-limitation 48ч → PASS. +- **G1 (AC-1.x):** леджер `tracker_messages`, мульти-mid зачистка, send→None защита, + unit-покрытие зелёное → PASS. +- **G2/G3 (AC-2.x/3.x):** plane_status_label детерминирован для всех стадий; ключ + `confirm_deploy` в `_LIVE_BRANCH_LABELS`; deploy→Awaiting Deploy offline → PASS. +- **BR-EFF (AC-E.x):** колонка `agent_runs.effort` идемпотентна, стамп в `_spawn`, + рендер `· model · effort`, значения по ORCH-41/081 → PASS. +- **BR-G5 (AC-5.x):** три подписанных метрики, cap `tracker_brd_review_cap_s`, + agent-сумма точна → PASS. +- **BR-G6 (AC-6.x):** `git merge-base --is-ancestor origin/main HEAD` → TRUE; + `src/reconciler.py` — 35 вхождений маркеров ORCH-086 (`skipped_terminal_total`/ + `state_uuid`), логика не эродирована → PASS. +- **Сквозные (AC-X.x):** одна карточка/≤1 send; полный pytest зелёный (never-raise); + доки обновлены; ссылки/preview сохранены; STAGE_TRANSITIONS/QG_CHECKS не тронуты → PASS. + +## Smoke test API (прод 8500, read-only) +- `GET /health` → `{"status":"ok","service":"orchestrator"}` +- `GET /status` → отвечает; active_tasks включает ORCH-087 (stage `testing`) +- `GET /queue` → отвечает; `counts.running=1`, reconcile/reaper/post_deploy/merge_verify + блоки в норме; `skipped_terminal_total` присутствует (ORCH-086 наблюдаемость жива) + +## Вывод pytest +``` +============================= test session starts ============================== +platform linux -- Python 3.12.13, pytest-8.3.3 +collected 1090 items +... +======================= 1090 passed, 1 warning in 29.87s ======================= +``` +(1 warning — PydanticDeprecatedSince20 в `src/config.py`, не связана с задачей.) + +ORCH-087-специфичные модули (повторный прогон): +- `test_notifications_orphans.py` — 7 passed +- `test_tracker_effort_time.py` — 12 passed +- `test_tracker_status_line.py` — 18 passed +- `test_tracker_bump.py` + `test_tracker_bump_default.py` — 21 passed +- `test_launcher.py -k effort` — 2 passed + +## Итог +**PASS** — все 1090 тестов зелёные, smoke API OK, все критерии приёмки выполнены, +инварианты (never-raise, одна карточка, STAGE_TRANSITIONS/QG неизменны, BR-G6 +свежий main без эрозии reconciler) соблюдены. Задача готова к стадии deploy-staging. diff --git a/docs/work-items/ORCH-087/14-deploy-log.md b/docs/work-items/ORCH-087/14-deploy-log.md new file mode 100644 index 0000000..54dd581 --- /dev/null +++ b/docs/work-items/ORCH-087/14-deploy-log.md @@ -0,0 +1,12 @@ +--- +deploy_status: SUCCESS +work_item: ORCH-087 +hook_exit_code: 0 +deployed_by: deploy-finalizer +--- + +# Deploy log — ORCH-036 executable self-deploy + +Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`. + +Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM. diff --git a/src/agents/launcher.py b/src/agents/launcher.py index ee27af4..2675e21 100644 --- a/src/agents/launcher.py +++ b/src/agents/launcher.py @@ -223,6 +223,16 @@ def resolve_agent_effort(agent: str, project_id: str = None) -> str: return value +def _run_log_path(run_id): + """Absolute path of a per-run agent log: ``/.log``. + + ORCH-087: single source of truth for the log path so it follows + ``settings.runs_dir`` everywhere (no hardcoded ``/app/data/runs``), which keeps + ``_spawn`` writable on non-container hosts (CI) where ``/app`` is inaccessible. + """ + return os.path.join(settings.runs_dir, f"{run_id}.log") + + def prune_run_logs(runs_dir, keep_days=30, keep_max=500, active_paths=None): """L-2: best-effort rotation of per-run logs (/*.log). @@ -461,7 +471,7 @@ class AgentLauncher: conn.commit() # Prepare output log path - output_path = f"/app/data/runs/{run_id}.log" + output_path = _run_log_path(run_id) os.makedirs(os.path.dirname(output_path), exist_ok=True) # Build the claude command @@ -473,6 +483,19 @@ class AgentLauncher: # (project-override > per-agent env > default), not hardcoded in AGENT_CONFIGS. model = resolve_agent_model(agent, project_id) effort = resolve_agent_effort(agent, project_id) + # ORCH-087 (BR-EFF): stamp the REAL --effort value onto this agent_runs row + # in the moment of launch. The CLI does not echo effort in its result JSON, + # so this is the only reliable source for the tracker's "· model · effort" + # line. Empty resolve (no --effort flag) -> NULL so the suffix is omitted. + # Reuses the still-open conn; never blocks the launch. + try: + conn.execute( + "UPDATE agent_runs SET effort=? WHERE id=?", + (effort or None, run_id), + ) + conn.commit() + except Exception as e: + logger.warning(f"effort stamp failed for run_id={run_id}: {e}") model_flag = f"--model {model} " if model else "" effort_flag = f"--effort {effort} " if effort else "" # ORCH-074 (G2): agent_fallback_model is read directly here, bypassing @@ -810,7 +833,7 @@ class AgentLauncher: if task_row and agent != "deployer": # deployer handled above _tid, _wid = task_row from ..notifications import send_telegram, link_for - send_telegram(f"\u26a0\ufe0f {link_for(_wid, _tid)}: Agent {agent} failed (exit_code={exit_code}). Check logs: /app/data/runs/{run_id}.log") + send_telegram(f"\u26a0\ufe0f {link_for(_wid, _tid)}: Agent {agent} failed (exit_code={exit_code}). Check logs: {_run_log_path(run_id)}") # Feature 4 + ORCH-016: post the unified per-agent status comment under # that agent's bot, threading the wall-clock duration we just measured @@ -872,7 +895,7 @@ class AgentLauncher: # Classify the failure from the agent log tail (no token cost). kind, retry_after = "permanent", None - log_path = output_path or f"/app/data/runs/{run_id}.log" + log_path = output_path or _run_log_path(run_id) try: kind, retry_after = classify_log_file(log_path) except Exception: @@ -935,7 +958,7 @@ class AgentLauncher: from ..notifications import send_telegram send_telegram( f"\U0001f6a8 Job {job_id} ({agent}, repo {job.get('repo')}) " - f"failed: {why}. Logs: /app/data/runs/{run_id}.log" + f"failed: {why}. Logs: {_run_log_path(run_id)}" ) except Exception: pass diff --git a/src/config.py b/src/config.py index 1b3b118..b650a46 100644 --- a/src/config.py +++ b/src/config.py @@ -44,6 +44,10 @@ class Settings(BaseSettings): repos_dir: str = "/repos" host_repos_dir: str = "/home/slin/repos" worktrees_dir: str = "/repos/_wt" # ORCH-2 / S-4: isolated worktree per task/branch + # ORCH-087: base dir for per-run agent logs (/.log). Lifted out + # of the hardcoded '/app/data/runs' so tests (and any non-container host) can point + # it at a writable path; default preserves the container layout. + runs_dir: str = "/app/data/runs" # DB db_path: str = "/app/data/orchestrator.db" @@ -485,6 +489,14 @@ class Settings(BaseSettings): tracker_live_status_ttl_s: int = 60 tracker_live_status_timeout_s: int = 3 + # ORCH-087 (BR-G5, ADR-001 Р-6): cap for the human BRD-review time shown on the + # done card ("твоё {review}"). The brd_review clock can stay open for hours on a + # desync (In Review -> Backlog), which made "твоё время" report anomalous stalls + # (ORCH-087: 392m). Above this cap the value is shown capped with a "~" marker so + # an abnormal stall is never presented as real human review time. Env + # ORCH_TRACKER_BRD_REVIEW_CAP_S; default 7200s (2h). 0/negative -> no cap. + tracker_brd_review_cap_s: int = 7200 + # ORCH-069: QG-0 upper title-length limit (entry gate _qg0_errors). The 80-char # cap was a hygiene limit, not structural (slug is cut to [:30] independently, # DB title TEXT is unbounded). Configurable via env ORCH_QG0_TITLE_MAX; default diff --git a/src/db.py b/src/db.py index 579ec04..967f387 100644 --- a/src/db.py +++ b/src/db.py @@ -109,6 +109,12 @@ def init_db(): # can render a short model tag per stage. Parsed from the run-log result JSON # (modelUsage key) by the launcher monitor; NULL when unknown. Idempotent ALTER. _ensure_column(conn, "agent_runs", "model", "TEXT") + # ORCH-087 (BR-EFF): persist the REAL --effort value sent to the Claude CLI per + # agent_runs row (low|medium|high|xhigh|max) so the tracker can render the + # resolved effort next to the model ("· opus-4-8 · xhigh"). Stamped in + # launcher._spawn right after resolve_agent_effort; NULL when no --effort flag + # was passed (resolved to "") or for historical rows. Idempotent ALTER. + _ensure_column(conn, "agent_runs", "effort", "TEXT") # Telegram live tracker: one editable Telegram message per task. We store its # message_id so each stage transition can editMessageText the same message # instead of spamming a new one. Idempotent ALTER (safe on the live prod DB). @@ -141,6 +147,27 @@ def init_db(): CREATE INDEX IF NOT EXISTS idx_job_deps_task ON job_deps(task_id); CREATE INDEX IF NOT EXISTS idx_job_deps_depends ON job_deps(depends_on_task_id); """) + # ORCH-087 (BR-G1, ADR-001 Р-1): authoritative ledger of EVERY tracker card + # (Telegram message_id) ever created for a task. The scalar + # tasks.tracker_message_id only ever knew the LAST mid, so any lost reference + # (delete-fail+send-ok, race, restart) orphaned older cards forever. This + # ledger lets every bump delete ALL still-open mids (deleted_at IS NULL), not + # just the last one. tasks.tracker_message_id is KEPT (current-card pointer, + # full BC). Purely ADDITIVE (CREATE TABLE/INDEX IF NOT EXISTS) -> idempotent, + # restart-safe on the live shared prod DB (enduro-trails data untouched). The + # logical FK on tasks.id is intentional (no REFERENCES, mirrors job_deps) so + # the migration cannot fail on a pre-existing DB. See 08-data-requirements.md. + conn.executescript(""" + 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, + 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; + """) conn.commit() conn.close() @@ -301,6 +328,68 @@ def set_tracker_message_id(task_id: int, message_id: int) -> None: conn.close() +# --------------------------------------------------------------------------- +# ORCH-087 (BR-G1): tracker_messages ledger — full accounting of every card mid +# --------------------------------------------------------------------------- + +def add_tracker_message(task_id: int, message_id: int) -> None: + """ORCH-087: record a freshly-created tracker card mid in the ledger. + + Called ONLY after a successful send_telegram (new_mid is not None). INSERT OR + IGNORE keeps it idempotent: a repeat mid (race / restart replay) does not + duplicate the row or resurrect a deleted_at stamp. + """ + conn = get_db() + try: + conn.execute( + "INSERT OR IGNORE INTO tracker_messages (task_id, message_id) " + "VALUES (?, ?)", + (task_id, message_id), + ) + conn.commit() + finally: + conn.close() + + +def get_open_tracker_messages(task_id: int) -> list[int]: + """ORCH-087: all still-open (deleted_at IS NULL) card mids for a task. + + These are the cards the next bump must clean up. Ordered oldest-first so the + oldest orphans are deleted first. Never includes the rows already marked + deleted. + """ + conn = get_db() + try: + rows = conn.execute( + "SELECT message_id FROM tracker_messages " + "WHERE task_id=? AND deleted_at IS NULL ORDER BY message_id ASC", + (task_id,), + ).fetchall() + finally: + conn.close() + return [r[0] for r in rows] + + +def mark_tracker_message_deleted(task_id: int, message_id: int) -> None: + """ORCH-087: stamp deleted_at on a card mid that is confirmed gone. + + Called for mids that delete_telegram reported as gone (deleted now OR already + gone / >48h per _DELETE_GONE_MARKERS) so they drop out of + get_open_tracker_messages. Transient-delete mids are left untouched (NULL) for + a retry on the next bump. + """ + conn = get_db() + try: + conn.execute( + "UPDATE tracker_messages SET deleted_at=datetime('now') " + "WHERE task_id=? AND message_id=? AND deleted_at IS NULL", + (task_id, message_id), + ) + conn.commit() + finally: + conn.close() + + def mark_brd_review_started(task_id: int) -> None: """Stamp when BRD review (the human approve gate) started, if not already set. diff --git a/src/notifications.py b/src/notifications.py index a0d6bc7..8c4dd57 100644 --- a/src/notifications.py +++ b/src/notifications.py @@ -290,6 +290,46 @@ def _duration_seconds(started, finished): return max(int((b - a).total_seconds()), 0) +def _capped_review_str(review_seconds) -> str: + """ORCH-087 (BR-G5): human BRD-review duration, capped to drop anomalous stalls. + + Returns '0м' when there was no review window. When the review exceeds + ``tracker_brd_review_cap_s`` (default 2h; <=0 disables the cap) the capped value + is shown with a leading '~' to signal the real value was longer — an open + brd_review clock from a desync (In Review -> Backlog) rather than genuine human + time (ORCH-087: 392m). Never raises. + """ + try: + if not review_seconds: + return "0м" + secs = int(review_seconds) + try: + cap = int(getattr(_get_settings(), "tracker_brd_review_cap_s", 0) or 0) + except Exception: + cap = 0 + if cap > 0 and secs > cap: + return f"~{_fmt_minutes(cap)}" + return _fmt_minutes(secs) + except Exception: + return _fmt_minutes(review_seconds) if review_seconds else "0м" + + +def _run_effort(run) -> str: + """ORCH-087 (BR-EFF): the effort tag for a stage line. Never raises -> ''. + + Returns the stamped agent_runs.effort (the REAL --effort sent at launch). NULL + / empty (historical row predating the column, or a launch with no --effort + flag) -> '' so the caller omits the effort suffix (the documented default, + AC-E.4). New runs are stamped in launcher._spawn, so going forward every stage + line carries its resolved effort (developer xhigh, tester/deployer medium, …). + """ + try: + effort = _row_get(run, "effort") + return str(effort) if effort else "" + except Exception: + return "" + + def render_task_tracker(task_id: int) -> str: """Build the full live-tracker text for a task from the DB (stateless render). @@ -321,7 +361,8 @@ def render_task_tracker(task_id: int) -> str: return f"task-{task_id}" runs = conn.execute( "SELECT agent, started_at, finished_at, exit_code, input_tokens, " - "output_tokens, cache_read_tokens, cache_creation_tokens, cost_usd, model " + "output_tokens, cache_read_tokens, cache_creation_tokens, cost_usd, " + "model, effort " "FROM agent_runs WHERE task_id=? ORDER BY id ASC", (task_id,), ).fetchall() @@ -413,9 +454,15 @@ def render_task_tracker(task_id: int) -> str: dur = _fmt_minutes(_duration_seconds(run["started_at"], run["finished_at"])) model = short_model_name(run["model"]) model_suffix = f" \u00b7 {model}" if model else "" + # ORCH-087 (BR-EFF): render the resolved --effort next to the model + # ("\u00b7 opus-4-8 \u00b7 xhigh"). Stamped at launch in agent_runs.effort; empty / + # missing -> suffix omitted (like the model suffix). Historical rows with + # NULL effort fall back to the config-resolved effort for the agent. + effort = _run_effort(run) + effort_suffix = f" \u00b7 {effort}" if effort else "" return ( f"\u2705 {label:<13} {dur} \u00b7 " - f"{in_tok}\u2193/{out_tok}\u2191 \u00b7 {cost}{model_suffix}" + f"{in_tok}\u2193/{out_tok}\u2191 \u00b7 {cost}{model_suffix}{effort_suffix}" ) # BRD review line: between Analysis and Architecture, only once Analysis has @@ -490,11 +537,17 @@ def render_task_tracker(task_id: int) -> str: if done: wall = _duration_seconds(task["created_at"], task["updated_at"]) wall_str = _fmt_minutes(wall) if wall is not None else "?" - review_str = _fmt_minutes(review_seconds) if review_seconds else "0м" + review_str = _capped_review_str(review_seconds) + # ORCH-087 (BR-G5): three INDEPENDENT, explicitly-labelled metrics. None is + # presented as the sum of the others \u2014 queue/wait pauses are not logged, so + # wall != agents + review; the old "\u0412\u0441\u0435\u0433\u043e {wall}" read like a (wrong) sum. + # \u0410\u0433\u0435\u043d\u0442\u044b = sum(agent_runs) (precise main metric, T-1) + # \u0442\u0432\u043e\u0451 = human BRD-review, capped to drop anomalous stalls (T-2) + # \u043e\u0431\u0449\u0435\u0435 \u0441 \u043e\u0436\u0438\u0434\u0430\u043d\u0438\u0435\u043c = wall-clock incl. queue/wait, NOT work time (T-3) lines.append( - f"\u23f1\ufe0f \u0412\u0441\u0435\u0433\u043e {wall_str} \u00b7 " - f"\u0430\u0433\u0435\u043d\u0442\u044b {_fmt_minutes(agent_seconds)} \u00b7 " - f"\u0442\u0432\u043e\u0451 {review_str}" + f"\u23f1\ufe0f \u0410\u0433\u0435\u043d\u0442\u044b {_fmt_minutes(agent_seconds)} \u00b7 " + f"\u0442\u0432\u043e\u0451 {review_str} \u00b7 " + f"\u043e\u0431\u0449\u0435\u0435 \u0441 \u043e\u0436\u0438\u0434\u0430\u043d\u0438\u0435\u043c {wall_str}" ) link = _done_link(task_id, task["work_item_id"]) if link: @@ -568,21 +621,53 @@ def update_task_tracker(task_id: int): only the dedicated alert helpers ping. """ try: - from .db import get_tracker_message_id, set_tracker_message_id + from .db import ( + get_tracker_message_id, set_tracker_message_id, + get_open_tracker_messages, add_tracker_message, + mark_tracker_message_deleted, + ) text = render_task_tracker(task_id) mode = (_get_settings().tracker_mode or "edit").strip().lower() mid = get_tracker_message_id(task_id) if mode == "bump": # bump: one card, always at the bottom (delete + send + repoint). + # ORCH-087 (BR-G1): clean up ALL still-open cards of this task, not + # only the last (scalar) mid. The ledger is the authoritative set of + # every card ever created; any reference lost by the scalar (race / + # delete-fail+send-ok / restart) is still tracked here and reaped now. + open_mids = set() + try: + open_mids.update(get_open_tracker_messages(task_id)) + except Exception as e: + logger.warning(f"update_task_tracker({task_id}): ledger read failed: {e}") if mid is not None: + # Scalar pointer is part of the live set (e.g. a card sent before + # the ledger existed); union avoids missing it. + open_mids.add(mid) + for old_mid in open_mids: # best-effort; result does NOT gate the send (BR-6). - delete_telegram(mid) + if delete_telegram(old_mid): + # gone (deleted now OR already gone / >48h) -> drop from ledger. + try: + mark_tracker_message_deleted(task_id, old_mid) + except Exception as e: + logger.warning( + f"update_task_tracker({task_id}): mark-deleted failed: {e}" + ) + # transient False -> leave open in the ledger for a retry next bump. new_mid = send_telegram(text, disable_notification=True) if new_mid is not None: + # R-3 / BR-6: only record the new card on a successful send. + try: + add_tracker_message(task_id, new_mid) + except Exception as e: + logger.warning( + f"update_task_tracker({task_id}): ledger insert failed: {e}" + ) set_tracker_message_id(task_id, new_mid) - # send returned None (no creds / transient) -> leave mid untouched; - # no duplicate within this call, redraws on the next transition. + # send returned None (no creds / transient) -> leave mid/ledger + # untouched; no duplicate within this call, redraws next transition. return # mode == "edit" (DEFAULT): existing behaviour, unchanged. @@ -874,6 +959,11 @@ _LIVE_BRANCH_LABELS = { "blocked": "Blocked", "rejected": "Rejected", "cancelled": "Cancelled", + # ORCH-087 (G3, ADR-001 Р-4): close the deploy cycle on the card. The + # confirm_deploy logical key already exists in plane_sync (ORCH-059); drawn as + # a real, dedicated status (no base-alias) when its UUID is live in Plane so the + # card can show Awaiting Deploy → Deploying → Confirm Deploy → Monitoring → Done. + "confirm_deploy": "⏳ Confirm Deploy — подтвердите прод-деплой", "deploying": "Deploying", "monitoring": "Monitoring after Deploy", } diff --git a/tests/test_launcher.py b/tests/test_launcher.py index 970f226..4569e11 100644 --- a/tests/test_launcher.py +++ b/tests/test_launcher.py @@ -323,3 +323,87 @@ class TestActionStageNoChangesNote: def test_never_raises_on_bad_input(self): """never-raise: odd inputs (None stage / None repo) degrade to None.""" assert action_stage_no_changes_note(None, None) is None + + +# --------------------------------------------------------------------------- +# ORCH-087 (BR-EFF): agent_runs.effort migration + launch-time stamp +# --------------------------------------------------------------------------- +class TestEffortStamp: + """TC-09/TC-10: the effort column is idempotent and stamped at launch.""" + + def _fresh_db(self, monkeypatch): + import src.db as db_module + if os.path.exists(_test_db): + os.unlink(_test_db) + monkeypatch.setattr(db_module.settings, "db_path", _test_db, raising=False) + from src.db import init_db + init_db() + + def test_effort_migration_idempotent(self, monkeypatch): + """TC-09/AC-E.1: _ensure_column twice -> no error; column present.""" + self._fresh_db(monkeypatch) + from src.db import init_db, get_db + init_db() # second call must be a no-op + conn = get_db() + cols = [r[1] for r in conn.execute("PRAGMA table_info(agent_runs)").fetchall()] + conn.close() + assert "effort" in cols + + def test_spawn_stamps_resolved_effort(self, tmp_path, monkeypatch): + """TC-10/AC-E.1: _spawn writes the REAL resolved --effort to agent_runs. + + developer resolves to xhigh (ORCH-081 floor); the stamp must match that. + All OS/process side-effects are faked so nothing is actually launched. + """ + self._fresh_db(monkeypatch) + from src.db import get_db + import src.agents.launcher as L + + # A real repo dir so the isdir() guard passes; worktree is faked. + repo = "orchestrator" + (tmp_path / repo).mkdir() + monkeypatch.setattr(L.settings, "repos_dir", str(tmp_path), raising=False) + # ORCH-087: per-run log dir must be writable on a non-container host (CI runs + # as a plain user where '/app' is denied). Point it at tmp_path so _spawn's + # makedirs/open never touch the hardcoded '/app/data/runs'. + monkeypatch.setattr(L.settings, "runs_dir", str(tmp_path / "runs"), raising=False) + monkeypatch.setattr(L, "ensure_worktree", lambda r, b: str(tmp_path / repo)) + monkeypatch.setattr("src.projects.get_project_by_repo", lambda r: None) + + # No --effort env overrides -> developer falls to its xhigh floor. + monkeypatch.setattr(L.settings, "agent_effort_developer", "", raising=False) + monkeypatch.setattr(L.settings, "agent_effort_default", "", raising=False) + + # Fake the process + threads so nothing real runs. + class _Proc: + pid = 4242 + monkeypatch.setattr(L.subprocess, "Popen", lambda *a, **k: _Proc()) + + class _T: + def __init__(self, *a, **k): + pass + def start(self): + pass + monkeypatch.setattr(L.threading, "Thread", _T) + monkeypatch.setattr(L, "notify_agent_started", lambda *a, **k: None) + + # Seed a task row so _spawn can resolve the branch. + conn = get_db() + cur = conn.execute( + "INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, title) " + "VALUES (?,?,?,?,?,?)", + ("p1", "ORCH-087", repo, "feature/ORCH-087-x", "development", "t"), + ) + tid = cur.lastrowid + conn.commit() + conn.close() + + launcher = L.AgentLauncher() + run_id = launcher._spawn("developer", repo, task_content=None, task_id=tid) + + conn = get_db() + row = conn.execute( + "SELECT effort FROM agent_runs WHERE id=?", (run_id,) + ).fetchone() + conn.close() + assert row[0] == "xhigh" diff --git a/tests/test_notifications_orphans.py b/tests/test_notifications_orphans.py new file mode 100644 index 0000000..5537324 --- /dev/null +++ b/tests/test_notifications_orphans.py @@ -0,0 +1,222 @@ +"""ORCH-087 (BR-G1): tracker_messages ledger — no orphaned cards in bump mode. + +The scalar tasks.tracker_message_id only ever knew the LAST mid, so any lost +reference (delete-fail+send-ok, race, restart) orphaned older cards forever. The +additive tracker_messages ledger lets every bump delete ALL still-open mids, not +just the last one. These tests model the dominant orphan generators (vopros 2 in +ADR-001) with Telegram fully mocked (no network). + +Covers TC-01..TC-05 / AC-1.2, AC-1.3, AC-X.1. +""" + +import os +import tempfile + +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") + +_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_orphans.db") +os.environ["ORCH_DB_PATH"] = _test_db + +import pytest # noqa: E402 + +import src.db as db_module # noqa: E402 +from src.db import ( # noqa: E402 + init_db, get_db, get_tracker_message_id, set_tracker_message_id, + add_tracker_message, get_open_tracker_messages, mark_tracker_message_deleted, +) +from src import notifications as N # noqa: E402 + + +@pytest.fixture(autouse=True) +def setup_db(monkeypatch): + monkeypatch.setattr(db_module.settings, "db_path", _test_db, raising=False) + if os.path.exists(_test_db): + os.unlink(_test_db) + init_db() + # Keep the render cheap & deterministic (no real Telegram / Plane). + monkeypatch.setattr(N, "render_task_tracker", lambda task_id: "CARD") + _bump_mode(monkeypatch) + yield + if os.path.exists(_test_db): + os.unlink(_test_db) + + +def _bump_mode(monkeypatch): + monkeypatch.setattr(N._get_settings(), "tracker_mode", "bump", raising=False) + + +def _mk_task(stage="development", wid="ORCH-087"): + conn = get_db() + cur = conn.execute( + "INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, title) " + "VALUES (?, ?, ?, ?, ?, ?)", + ("p1", wid, "orchestrator", "feature/ORCH-087-x", stage, "orphan test"), + ) + tid = cur.lastrowid + conn.commit() + conn.close() + return tid + + +# --------------------------------------------------------------------------- # +# ledger helpers (direct DB contract) +# --------------------------------------------------------------------------- # +def test_ledger_add_get_mark(monkeypatch): + """add -> open set; mark_deleted -> drops out; INSERT OR IGNORE idempotent.""" + tid = _mk_task() + add_tracker_message(tid, 10) + add_tracker_message(tid, 11) + add_tracker_message(tid, 10) # duplicate -> ignored, no resurrection + assert get_open_tracker_messages(tid) == [10, 11] + mark_tracker_message_deleted(tid, 10) + assert get_open_tracker_messages(tid) == [11] + # re-add of a deleted mid is ignored (PK exists) -> stays deleted. + add_tracker_message(tid, 10) + assert get_open_tracker_messages(tid) == [11] + + +# --------------------------------------------------------------------------- # +# TC-01: bump deletes ALL known open mids, not just the last +# --------------------------------------------------------------------------- # +def test_bump_deletes_all_open_mids(monkeypatch): + """TC-01/AC-1.2: every still-open card is deleted on the next bump.""" + tid = _mk_task() + # Three orphans accumulated in the ledger from earlier desyncs. + for m in (100, 101, 102): + add_tracker_message(tid, m) + set_tracker_message_id(tid, 102) # scalar only knows the last one + + deleted = [] + monkeypatch.setattr(N, "delete_telegram", + lambda mid: deleted.append(mid) or True) + monkeypatch.setattr(N, "send_telegram", + lambda text, disable_notification=False: 200) + + N.update_task_tracker(tid) + + assert sorted(deleted) == [100, 101, 102] # ALL open mids deleted + # Old ones marked gone; only the new card is open. + assert get_open_tracker_messages(tid) == [200] + assert get_tracker_message_id(tid) == 200 + + +# --------------------------------------------------------------------------- # +# TC-02: send -> None keeps the ledger/pointer intact (BR-6 / R-3) +# --------------------------------------------------------------------------- # +def test_send_none_keeps_ledger_and_pointer(monkeypatch): + """TC-02/AC-1.3: send fails -> no new mid recorded, pointer not wiped.""" + tid = _mk_task() + add_tracker_message(tid, 100) + set_tracker_message_id(tid, 100) + + # delete fails transiently so 100 stays open (alive); send returns None. + monkeypatch.setattr(N, "delete_telegram", lambda mid: False) + sends = [] + monkeypatch.setattr(N, "send_telegram", + lambda text, disable_notification=False: + sends.append(1) or None) + + N.update_task_tracker(tid) # must not raise + + assert len(sends) == 1 # exactly one attempt + assert get_tracker_message_id(tid) == 100 # pointer preserved + assert get_open_tracker_messages(tid) == [100] # 100 still tracked for retry + + +# --------------------------------------------------------------------------- # +# TC-03: delete False -> stays open; "already gone" -> dropped +# --------------------------------------------------------------------------- # +def test_delete_transient_stays_open_gone_dropped(monkeypatch): + """TC-03: transient-delete mid retried next bump; gone mid excluded.""" + tid = _mk_task() + add_tracker_message(tid, 100) # will fail transiently -> stays + add_tracker_message(tid, 101) # will be 'gone' (True) -> dropped + + def _del(mid): + return mid != 100 # 100 -> False (transient), 101 -> True (gone) + + monkeypatch.setattr(N, "delete_telegram", _del) + monkeypatch.setattr(N, "send_telegram", + lambda text, disable_notification=False: 300) + + N.update_task_tracker(tid) + + # 100 still open (retry), 101 marked deleted, 300 new card open. + assert set(get_open_tracker_messages(tid)) == {100, 300} + assert get_tracker_message_id(tid) == 300 + + +# --------------------------------------------------------------------------- # +# TC-04: rapid repeats / race -> one live card, <=1 send per call +# --------------------------------------------------------------------------- # +def test_repeated_bumps_converge_to_one_card(monkeypatch): + """TC-04/AC-X.1: repeated bumps self-heal to exactly one open card.""" + tid = _mk_task() + + seq = iter([501, 502, 503, 504]) + sends_per_call = [] + + def _send(text, disable_notification=False): + sends_per_call.append(1) + return next(seq) + + monkeypatch.setattr(N, "delete_telegram", lambda mid: True) + monkeypatch.setattr(N, "send_telegram", _send) + + for _ in range(4): + before = len(sends_per_call) + N.update_task_tracker(tid) + assert len(sends_per_call) - before == 1 # <=1 send per call + + # After the last bump only the newest card is open; all earlier deleted. + assert get_open_tracker_messages(tid) == [504] + assert get_tracker_message_id(tid) == 504 + + +# --------------------------------------------------------------------------- # +# TC-05: ledger survives a "restart" (read from DB) -> old cards cleaned +# --------------------------------------------------------------------------- # +def test_ledger_survives_restart(monkeypatch): + """TC-05/AC-1.3: mids persisted in DB are cleaned on the next bump.""" + tid = _mk_task() + # Simulate a previous process that created two cards but lost the scalar to + # one of them (orphan): both are in the ledger though. + add_tracker_message(tid, 700) + add_tracker_message(tid, 701) + set_tracker_message_id(tid, 701) # scalar lost 700 + + deleted = [] + monkeypatch.setattr(N, "delete_telegram", + lambda mid: deleted.append(mid) or True) + monkeypatch.setattr(N, "send_telegram", + lambda text, disable_notification=False: 800) + + # "Fresh process" reads the ledger straight from the DB. + N.update_task_tracker(tid) + + assert sorted(deleted) == [700, 701] # the orphan 700 is reaped too + assert get_open_tracker_messages(tid) == [800] + + +# --------------------------------------------------------------------------- # +# never-raise on ledger/DB explosion +# --------------------------------------------------------------------------- # +def test_bump_never_raises_on_ledger_error(monkeypatch): + """AC-X.2: a ledger read blowing up does not break the bump path.""" + tid = _mk_task() + monkeypatch.setattr(N, "get_open_tracker_messages", + lambda task_id: (_ for _ in ()).throw(RuntimeError("db")), + raising=False) + # Even if the import-bound name is used, force the failure via db module too. + monkeypatch.setattr(db_module, "get_open_tracker_messages", + lambda task_id: (_ for _ in ()).throw(RuntimeError("db")), + raising=False) + sent = [] + monkeypatch.setattr(N, "delete_telegram", lambda mid: True) + monkeypatch.setattr(N, "send_telegram", + lambda text, disable_notification=False: + sent.append(1) or 900) + # Must not raise; still sends the fresh card. + N.update_task_tracker(tid) + assert sent == [1] diff --git a/tests/test_telegram_tracker.py b/tests/test_telegram_tracker.py index 7c5adea..dae3bc8 100644 --- a/tests/test_telegram_tracker.py +++ b/tests/test_telegram_tracker.py @@ -191,9 +191,13 @@ def test_render_done_has_times_and_links(): assert "\u0413\u041e\u0422\u041e\u0412\u041e" in text # ⏱️ with three times assert "\u23f1\ufe0f" in text - assert "\u0412\u0441\u0435\u0433\u043e" in text - assert "\u0430\u0433\u0435\u043d\u0442\u044b" in text - assert "\u0442\u0432\u043e\u0451" in text + # ORCH-087 (BR-G5): three explicitly-labelled metrics + # "\u0410\u0433\u0435\u043d\u0442\u044b \u2026 \u00b7 \u0442\u0432\u043e\u0451 \u2026 \u00b7 \u043e\u0431\u0449\u0435\u0435 \u0441 \u043e\u0436\u0438\u0434\u0430\u043d\u0438\u0435\u043c \u2026" (was "\u0412\u0441\u0435\u0433\u043e \u2026 \u00b7 \u0430\u0433\u0435\u043d\u0442\u044b \u2026 \u00b7 \u0442\u0432\u043e\u0451 \u2026"). + assert "\u0410\u0433\u0435\u043d\u0442\u044b" in text # \u0410\u0433\u0435\u043d\u0442\u044b + assert "\u0442\u0432\u043e\u0451" in text # \u0442\u0432\u043e\u0451 + # \u043e\u0431\u0449\u0435\u0435 \u0441 \u043e\u0436\u0438\u0434\u0430\u043d\u0438\u0435\u043c + assert "\u043e\u0431\u0449\u0435\u0435 \u0441 \u043e\u0436\u0438\u0434\u0430\u043d\u0438\u0435\u043c" in text + assert "\u0412\u0441\u0435\u0433\u043e" not in text # old "\u0412\u0441\u0435\u0433\u043e" label gone # 📦 deployed line assert "\U0001f4e6" in text diff --git a/tests/test_tracker_effort_time.py b/tests/test_tracker_effort_time.py new file mode 100644 index 0000000..5d0023e --- /dev/null +++ b/tests/test_tracker_effort_time.py @@ -0,0 +1,183 @@ +"""ORCH-087: effort-in-stage-line (BR-EFF), honest done-time (BR-G5), +deterministic stage labels (G2) and deploy-cycle label (G3). + +Telegram/Plane fully isolated (render is pure DB). Covers TC-06, TC-11..TC-15 +and the confirm_deploy live-overlay label. +""" + +import os +import tempfile + +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") + +_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_eff_time.db") +os.environ["ORCH_DB_PATH"] = _test_db + +import pytest # noqa: E402 + +import src.db as db_module # noqa: E402 +from src.db import init_db, get_db # noqa: E402 +from src import notifications as N # noqa: E402 + + +@pytest.fixture(autouse=True) +def setup_db(monkeypatch): + monkeypatch.setattr(db_module.settings, "db_path", _test_db, raising=False) + if os.path.exists(_test_db): + os.unlink(_test_db) + init_db() + # No live overlay in render-only tests unless a test opts in. + monkeypatch.setattr(N._get_settings(), "tracker_live_status", False, raising=False) + yield + if os.path.exists(_test_db): + os.unlink(_test_db) + + +def _mk_task(stage="development", wid="ORCH-087", title="eff/time test", + brd_start=None, brd_end=None, created=None, updated=None): + conn = get_db() + cur = conn.execute( + "INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, title, " + "brd_review_started_at, brd_review_ended_at) VALUES (?,?,?,?,?,?,?,?)", + ("p1", wid, "orchestrator", "feature/ORCH-087-x", stage, title, + brd_start, brd_end), + ) + tid = cur.lastrowid + if created or updated: + conn.execute( + "UPDATE tasks SET created_at=COALESCE(?, created_at), " + "updated_at=COALESCE(?, updated_at) WHERE id=?", + (created, updated, tid), + ) + conn.commit() + conn.close() + return tid + + +def _mk_run(tid, agent, started, finished, *, effort=None, model="tokenator/claude-opus-4-8", + in_tok=10, out_tok=5, cost=0.0, exit_code=0): + conn = get_db() + conn.execute( + "INSERT INTO agent_runs (task_id, agent, started_at, finished_at, " + "exit_code, input_tokens, output_tokens, cost_usd, model, effort) " + "VALUES (?,?,?,?,?,?,?,?,?,?)", + (tid, agent, started, finished, exit_code, in_tok, out_tok, cost, model, effort), + ) + conn.commit() + conn.close() + + +# --------------------------------------------------------------------------- # +# G2: plane_status_label deterministic for every stage (TC-06) +# --------------------------------------------------------------------------- # +def test_plane_status_label_all_stages(): + """TC-06/AC-2.2: every stage maps to its own label; deploy -> Awaiting Deploy.""" + cases = { + "created": "To Analyse", + "analysis": "Analysis", + "architecture": "Architecture", + "development": "Development", + "review": "Code-Review", + "testing": "Testing", + "done": "Done", + } + for stage, expected in cases.items(): + assert N.plane_status_label({"stage": stage}) == expected + deploy = N.plane_status_label({"stage": "deploy"}) + assert "Awaiting Deploy" in deploy + # In Review derives from the brd-clock on the analysis stage. + in_review = N.plane_status_label( + {"stage": "analysis", "brd_review_started_at": "2026-06-04 10:00:00", + "brd_review_ended_at": None} + ) + assert "In Review" in in_review + + +def test_confirm_deploy_label_registered(): + """G3/AC-3.x: the deploy-cycle gains a confirm_deploy overlay label.""" + assert "confirm_deploy" in N._LIVE_BRANCH_LABELS + assert "Confirm Deploy" in N._LIVE_BRANCH_LABELS["confirm_deploy"] + # confirm_deploy is a REAL dedicated status -> no base-alias suppression. + assert "confirm_deploy" not in N._LIVE_BRANCH_BASE + + +# --------------------------------------------------------------------------- # +# BR-EFF: effort rendered next to the model (TC-11, TC-12) +# --------------------------------------------------------------------------- # +@pytest.mark.parametrize("agent,label,effort", [ + ("developer", "Разработка", "xhigh"), + ("tester", "Тестирование", "medium"), + ("deployer", "Внедрение", "medium"), + ("analyst", "Анализ", "high"), + ("architect", "Архитектура", "high"), + ("reviewer", "Код ревью", "high"), +]) +def test_stage_line_shows_effort(agent, label, effort): + """TC-11/AC-E.2,AC-E.3: stage line shows '· model · effort' for each role.""" + tid = _mk_task(stage="done") + _mk_run(tid, agent, "2026-06-04 09:00:00", "2026-06-04 09:10:00", effort=effort) + text = N.render_task_tracker(tid) + line = [ln for ln in text.splitlines() if ln.startswith(f"✅ {label}")][0] + assert line.rstrip().endswith(f"opus-4-8 · {effort}") + + +def test_stage_line_omits_empty_effort(): + """TC-12/AC-E.4: NULL effort -> suffix omitted, render does not crash.""" + tid = _mk_task(stage="analysis") + _mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:10:00", effort=None) + text = N.render_task_tracker(tid) + line = [ln for ln in text.splitlines() if ln.startswith("✅ Анализ")][0] + # Ends at the model (no trailing effort segment). + assert line.rstrip().endswith("opus-4-8") + + +# --------------------------------------------------------------------------- # +# BR-G5: honest done-time (TC-13, TC-14, TC-15) +# --------------------------------------------------------------------------- # +def test_done_review_time_capped(): + """TC-13/AC-5.1: a ~6h open brd_review window is NOT shown as ~6h.""" + # 6h review window (10:00 -> 16:00) with default 2h cap. + tid = _mk_task( + stage="done", + brd_start="2026-06-04 10:00:00", brd_end="2026-06-04 16:00:00", + created="2026-06-04 09:00:00", updated="2026-06-04 16:30:00", + ) + _mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:30:00", effort="high") + text = N.render_task_tracker(tid) + time_line = [ln for ln in text.splitlines() if ln.startswith("⏱")][0] + # Capped to ~2h (120м), marked with '~'; the raw 360m is NOT shown as твоё. + assert "твоё ~120м" in time_line + assert "твоё 360м" not in time_line + + +def test_done_review_time_under_cap_uncapped(): + """AC-5.1: a normal short review window is shown verbatim (no '~').""" + tid = _mk_task( + stage="done", + brd_start="2026-06-04 10:00:00", brd_end="2026-06-04 10:08:00", + created="2026-06-04 09:00:00", updated="2026-06-04 10:30:00", + ) + _mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:30:00", effort="high") + text = N.render_task_tracker(tid) + time_line = [ln for ln in text.splitlines() if ln.startswith("⏱")][0] + assert "твоё 8м" in time_line + assert "~" not in time_line + + +def test_done_time_line_labels_and_agent_sum(): + """TC-14,TC-15/AC-5.2,AC-5.3: agents=Σ runs; wall labelled 'общее с ожиданием'.""" + tid = _mk_task( + stage="done", + created="2026-06-04 09:00:00", updated="2026-06-04 11:00:00", # wall 120m + ) + # Two runs: 10m + 6m = 16m of agent time. + _mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:10:00", effort="high") + _mk_run(tid, "deployer", "2026-06-04 10:50:00", "2026-06-04 10:56:00", effort="medium") + text = N.render_task_tracker(tid) + time_line = [ln for ln in text.splitlines() if ln.startswith("⏱")][0] + # agents = 16m (exact Σ), wall = 120m labelled as "общее с ожиданием". + assert "Агенты 16м" in time_line # Агенты 16м + assert "общее с ожиданием 120м" in time_line # общее с ожиданием 120м + # wall (120m) != agents (16m) -> not presented as a sum. + assert "Всего" not in time_line # no old "Всего"