diff --git a/.env.example b/.env.example index 42feaf5..9882c61 100644 --- a/.env.example +++ b/.env.example @@ -12,3 +12,8 @@ ORCH_GITEA_WEBHOOK_SECRET= ORCH_CLAUDE_BIN=/usr/bin/claude ORCH_REPOS_DIR=/home/slin/repos ORCH_DB_PATH=/app/data/orchestrator.db +# ORCH-042: live-tracker mode. edit (DEFAULT) -> the task card is edited in place +# (editMessageText). bump -> on every update the old card is deleted and a fresh +# one is sent silently to the BOTTOM of the chat (deleteMessage + sendMessage + +# repoint). One card per task in both modes. Any value other than "bump" -> edit. +ORCH_TRACKER_MODE=edit diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e3cbfb..cea7f17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ## [Unreleased] ### Added +- **Режим `bump` live-трекера Telegram** (ORCH-042): новый `ORCH_TRACKER_MODE` (`Settings.tracker_mode`, дефолт `edit`) выбирает поведение карточки задачи. `edit` (как было) — карточка редактируется на месте (`editMessageText`). `bump` — на каждом обновлении старое сообщение удаляется и карточка отправляется заново вниз чата (best-effort `delete_telegram(старый_id)` → `send_telegram(text, disable_notification=True)` → `set_tracker_message_id(new_id)`), чтобы актуальный статус всегда был последним в чате при активной переписке. Инвариант «одна карточка на задачу» сохранён в обоих режимах: за один вызов `update_task_tracker` шлётся ≤1 нового сообщения; `set_tracker_message_id` вызывается ТОЛЬКО при успешном send (транзиентный `None` не затирает указатель); результат delete НЕ блокирует отправку новой карточки (delete-fail у сообщения >48ч → всё равно шлём новое). Резолюция режима в `notifications` (case-insensitive, trim): всё, что ≠ `"bump"` (включая пустое/мусор) → `edit` → нулевая регрессия и оркестратор не падает на любом значении флага. Новый low-level helper `delete_telegram(message_id) -> bool` (контракт «never raises», маркеры `_DELETE_GONE_MARKERS`): `ok:true` или «уже нет / нельзя удалить» → `True`; неизвестный `ok:false`/5xx/исключение → `False`; нет кредов → `False` без HTTP. Сигнатуры `send_telegram`/`edit_telegram`/`update_task_tracker` и схема БД (`tasks.tracker_message_id`) не менялись. ADR `docs/work-items/ORCH-042/06-adr/ADR-001-tracker-bump-mode.md`. Тесты: `tests/test_tracker_bump.py`, `tests/test_config.py`. - **Дословный текст findings reviewer/tester встраивается в `task_desc` заворота** (ORCH-046): при откате на `development` строка `task_desc` (попадает в `.task-dev.md` developer-агента) теперь несёт суть претензий, а не только ссылку на файл — устраняет «испорченный телефон», из-за которого агент шёл «читать файл», терял ключевые P0/P1 / причину FAIL и заворачивался снова, выжигая `MAX_DEVELOPER_RETRIES` и токены. Новый defensive-модуль `src/review_parse.py` (контракт «never raise», как `src/frontmatter.py`): `extract_review_findings(path)` — дословные пункты P0/P1 из секции `## Findings` файла `12-review.md`; `extract_test_failures(path)` — релевантный фрагмент тела `13-test-report.md` (приоритет `## Вывод pytest` → FAIL-строки `## Результаты` → `## Итог`). Обе функции усекают результат до `MAX_FINDINGS_CHARS`/`MAX_FAILURES_CHARS` (≈2000) с маркером `…(truncated)`. Две rollback-ветки `src/stage_engine.py` (reviewer REQUEST_CHANGES, tester `check_tests_passed` FAIL) встраивают извлечённый текст и **сохраняют ссылку** на полный файл («Полный контекст»); при пустом/битом артефакте — graceful-фоллбэк на прежнюю ссылку-строку (никаких исключений в `advance_stage`). Tester-ветка дополнительно всегда включает `reason` гейта. Последовательность отката, `_developer_retry_count`, поля `AdvanceResult` и реестр `QG_CHECKS` не менялись. ADR `docs/work-items/ORCH-046/06-adr/ADR-001-embed-findings-in-task-desc.md`. Тесты: `tests/test_review_parse.py`, `tests/test_stage_engine.py::TestRollbackTaskDescEmbedding`. - **Поллинг с ретраем в quality-gate `check_ci_green`** (ORCH-045): гейт CI превращён из single-shot в polling, чтобы устранить race condition — раньше один опрос combined commit-status сразу после пуша developer-а ловил транзиентный `pending` (типично 1-3с, реальный кейс ORCH-017: опрос 17:58:54 → pending, CI дозеленел 17:58:55) и задача застревала насмерть без повторного опроса. Теперь: `success` → пропуск сразу; `failure`/`error` → провал сразу (терминально, ретрай бессмыслен); `pending`/unknown → `time.sleep` и повторный опрос до `ci_poll_max_attempts` раз; истечение попыток → явный `(False, "CI still pending after s")` (тупик больше не молчаливый); 404 → как раньше; транзиентная `httpx.HTTPError` на попытке логируется и ретраится в рамках бюджета. Параметры — новые настройки `ORCH_CI_POLL_MAX_ATTEMPTS` (12) и `ORCH_CI_POLL_INTERVAL_S` (10) в `src/config.py` (~2 мин ожидания pending). Сигнатура `check_ci_green(repo, branch)` и реестр `QG_CHECKS` не менялись; `check_tests_passed` не затронут. ADR `docs/architecture/adr/adr-0004-ci-poll-retry.md`. Тесты: `tests/test_qg.py::TestCheckCIGreen`. - **Прямые ссылки на BRD и Plane-таску в Telegram-уведомлении об апруве** (ORCH-017): пингующее сообщение `notify_approve_requested` теперь встраивает две HTML-``-ссылки — на `docs/work-items//01-brd.md` (Gitea branch-view: `gitea_public_url`→`gitea_url`) и на issue в Plane (`{web_base}/{workspace}/projects/{project_id}/issues/{plane_issue_id}/`). Новая настройка `ORCH_PLANE_WEB_URL` (внешний браузерный web-URL Plane; фолбэк на `plane_api_url`). **Loopback-guard:** если итоговый Plane web-base указывает на localhost/127.0.0.1/0.0.0.0/::1 или пуст — Plane-ссылка опускается (не выпускаем битый localhost-URL). Graceful degradation: каждая ссылка строится независимо и опускается при нехватке данных, сообщение и призыв «Переведите задачу в статус Approved …» сохраняются всегда; ровно одно пингующее сообщение, разделяемая `send_telegram` не тронута. Динамические подписи экранируются `html.escape`, `parse_mode=HTML` сохранён. ADR `docs/work-items/ORCH-017/06-adr/ADR-001-telegram-approve-links.md`. Тесты: `test_notify_approve_links.py`, `test_analysis_approve_flow_links.py`. @@ -19,6 +20,7 @@ - **Реестр проектов** (ORCH-6): `src/projects.py`, фильтрация вебхуков по проекту. ### Changed +- **Русификация и косметика карточки live-трекера Telegram** (ORCH-042, оба режима): метка `Подтверждение BRD` вместо «Ревью БРД» (`_BRD_LABEL`); после прохождения approve-gate строка подтверждения BRD начинается с ✅ вместо ⏸️ (ветка ожидания человека сохраняет ⏸️/⏳); русские display-labels стадий в `_TRACKER_STAGES` (`Анализ / Архитектура / Разработка / Код ревью / Тестирование / Внедрение`) — применяются и в «✅ …», и в «🔄 … идёт»; финальная строка готовой задачи `📦 Внедрено` вместо `deployed` (`_done_link`). Меняются только отображаемые строки — ключи стадий и имена агентов не трогаются. Существующие ассерты `tests/test_telegram_tracker.py` обновлены под русские метки. - **Status-коммент агентов теперь HTML и единообразен** (ORCH-016): `src/usage.usage_comment(...)` помечен deprecated и стал тонкой обёрткой над `build_status_comment`; `src/usage.artifact_links(...)` теперь возвращает `
  • ` HTML-фрагменты (раньше — markdown `[label](url)`); `stage_engine._build_analyst_ready_comment(...)` — тонкая обёртка, аналитик идёт через ту же ветку `build_status_comment(agent="analyst", ...)`. Реестр `QG_CHECKS` и `STAGE_TRANSITIONS` НЕ изменялись. - Цепочка стадий: `... testing → deploy-staging → deploy → done` (была без `deploy-staging`). diff --git a/docs/architecture/internals.md b/docs/architecture/internals.md index 3e01346..0336bd9 100644 --- a/docs/architecture/internals.md +++ b/docs/architecture/internals.md @@ -107,6 +107,27 @@ claude.exe --print --system-prompt --allowedTools Read,Write,Edit,Bash 2. Если < MAX_DEV_RETRIES (3) — откатывает в development, перезапускает developer 3. Если >= MAX_DEV_RETRIES — эскалация (логирование + уведомление) +### 7. Live Telegram tracker (`src/notifications.py`) + +Вместо ~15 отдельных сообщений на задачу оркестратор держит **ОДНУ** live-карточку на задачу (`update_task_tracker`), которая обновляется на каждом переходе стадии. Текст рендерится статически из БД (`render_task_tracker`: стадии, токены, стоимость, BRD-подтверждение, итоги). Карточка всегда тихая (`disable_notification=True`); отдельные пинги шлют только `notify_approve_requested` / `notify_error`. `message_id` хранится в `tasks.tracker_message_id`; helpers `get_tracker_message_id` / `set_tracker_message_id`. Контракт всего компонента — **never raises**. + +**Режимы (ORCH-042, `ORCH_TRACKER_MODE` → `Settings.tracker_mode`).** Резолвится в `update_task_tracker` (case-insensitive, trim); всё, что ≠ `"bump"` (включая пустое/мусор/None), трактуется как `edit` → нулевая регрессия и безопасный фолбэк. Инвариант «одна карточка на задачу» сохраняется в обоих режимах. + +| Режим | Поведение при обновлении | +|-------|--------------------------| +| `edit` (дефолт) | первый вызов → `send_telegram` (тихо) + сохранение `message_id`; далее → `edit_telegram` на сохранённый id. Новое сообщение шлётся ТОЛЬКО при `EDIT_GONE` (удалено/старше 48ч/невалидный id). `EDIT_NOT_MODIFIED` / `EDIT_FAILED` → нового сообщения нет (анти-дубль). | +| `bump` | карточка пересоздаётся внизу чата: best-effort `delete_telegram(старый_id)` → `send_telegram(text, disable_notification=True)` → `set_tracker_message_id(new_id)` **только** при успешном send (`new_mid is not None`). За один вызов — не более одного нового сообщения. | + +**`delete_telegram(message_id) -> bool`** (low-level, never raises). Семантика возврата — «исчезло ли старое сообщение»: +- `ok:true` → `True`; +- `ok:false` с маркерами `_DELETE_GONE_MARKERS` (`message to delete not found`, `message can't be deleted`, `message_id_invalid`) → `True` (старше 48ч / уже удалено — не транзиент); +- прочий `ok:false` / 5xx / исключение (сеть/таймаут) → `False` + `logger.warning`; +- нет токена/chat_id → `False`, HTTP не выполняется. + +Результат `delete_telegram` **не** блокирует отправку новой карточки (BR-6: delete-fail у сообщения >48ч → всё равно шлём новое); `False` означает лишь «старое, возможно, ещё живо» — будет вычищено повторной попыткой на следующем переходе. При транзиентном сбое send (`None`) указатель `tracker_message_id` **не** затирается (анти-затирание, симметрично edit-fallback). + +**Текст карточки (оба режима, ORCH-042):** метка `Подтверждение BRD` (была «Ревью БРД»); после прохождения approve-gate строка BRD начинается с ✅ (ветка ожидания сохраняет ⏸️/⏳); русские display-labels стадий (`Анализ / Архитектура / Разработка / Код ревью / Тестирование / Внедрение`); финальная строка `📦 Внедрено` (было `deployed`). Меняются только отображаемые строки — ключи стадий и имена агентов (завязаны на `_STAGE_ACTIVE_AGENT`, `last_done`, БД) не трогаются. + ## Database Schema ```sql diff --git a/docs/work-items/ORCH-042/00-business-request.md b/docs/work-items/ORCH-042/00-business-request.md new file mode 100644 index 0000000..3be728b --- /dev/null +++ b/docs/work-items/ORCH-042/00-business-request.md @@ -0,0 +1,7 @@ +# Business Request: Telegram live-tracker: режим bump (карточка падает вниз при обновлении) + +Work Item ID: ORCH-042 + +## Description + +TBD diff --git a/docs/work-items/ORCH-042/01-brd.md b/docs/work-items/ORCH-042/01-brd.md new file mode 100644 index 0000000..28857ec --- /dev/null +++ b/docs/work-items/ORCH-042/01-brd.md @@ -0,0 +1,65 @@ +# 01 — BRD: Telegram live-tracker, режим bump + русификация карточки + +**Work Item:** ORCH-042 +**Тип:** UX-улучшение (notifications) +**Приоритет:** средний +**Запрос:** Слава, 05.06. Связь: `feat/telegram-live-tracker` (Variant B+). +**Self-hosting:** да — правка самого оркестратора, проходит через его же конвейер (общая БД/очередь с enduro-trails). См. `docs/operations/INFRA.md`. + +## 1. Контекст и проблема + +Live-tracker задачи (`src/notifications.py`) — это ОДНА карточка на задачу в Telegram, которая обновляется на каждом переходе стадии через `editMessageText` (Variant B+). Так сделано СПЕЦИАЛЬНО, чтобы убить старую проблему «~15 отдельных карточек/дублей на задачу». + +Побочный эффект текущего решения: карточка редактируется **на месте в истории чата**. При активной переписке в чате карточка «тонет» вверху и её неудобно искать — приходится скроллить вверх к старому сообщению, чтобы увидеть актуальный статус задачи. + +Дополнительно накопились косметические претензии к тексту карточки: смесь англоязычных меток стадий с русским текстом, неудачная формулировка «Ревью БРД», и финальный технический хвост `deployed` вместо человекочитаемого «Внедрено». + +## 2. Цель + +1. Дать Славе альтернативный режим отображения трекера — **bump**: при каждом обновлении карточка «падает вниз» свежим сообщением (всегда последняя в чате), но БЕЗ возврата к проблеме дублей (по-прежнему ОДНА карточка на задачу) и БЕЗ спама звуками/пингами. +2. Привести текст карточки к единому русскому виду и поправить формулировки. + +## 3. Заинтересованные лица + +- **Слава (Owner)** — единственный получатель Telegram-уведомлений; принимает UX. +- **Агенты конвейера** — косвенно: трекер обновляется из `notify_*`-хелперов на каждой стадии. + +## 4. Требования (бизнес-уровень) + +### 4.1. Режим работы трекера (флаг) +- **BR-1.** Новый конфиг-флаг `ORCH_TRACKER_MODE` с двумя значениями: + - `edit` — текущее поведение (редактирование на месте). **Это ДЕФОЛТ** (обратная совместимость, никакой регрессии без явного включения). + - `bump` — новый режим «карточка падает вниз». +- **BR-2.** Неизвестное/пустое значение флага трактуется как `edit` (безопасный фолбэк, оркестратор не падает). + +### 4.2. Поведение режима bump +- **BR-3.** При обновлении карточки в режиме `bump`: старое сообщение удаляется (`deleteMessage`), отправляется новое (`sendMessage`), указатель `tracker_message_id` перенаправляется на новое сообщение. Итог: в чате всегда ровно ОДНА карточка задачи, и она всегда внизу. +- **BR-4.** Bump тихий: новое сообщение отправляется с `disable_notification=true` — карточка всплывает внизу, но БЕЗ звука/пинга на каждой стадии (как и сейчас в edit-режиме). +- **BR-5.** Первое обновление (карточки ещё нет) в режиме `bump` — просто тихо отправить новое и запомнить id (удалять нечего). + +### 4.3. Устойчивость (критично — не сломать защиту от дублей) +- **BR-6.** Fallback: если `deleteMessage` не удался (сообщение старше 48 ч / уже удалено / недоступно) — карточка всё равно отправляется заново, оркестратор НЕ падает. +- **BR-7.** Любой сбой нотификации (сеть/таймаут/5xx/Telegram-ошибка) НЕ роняет оркестратор (контракт «never raises» сохраняется) и НЕ плодит дубли карточек в пределах одного обновления. +- **BR-8.** Режим `edit` после изменений работает строго как раньше — без регрессий (защита от ~15 дублей сохранена). + +### 4.4. Текстовые правки карточки (применяются в ОБОИХ режимах) +- **BR-9.** Метку «Ревью БРД» заменить на «Подтверждение BRD». +- **BR-10.** После того как задача переведена в Approved (человеческий gate пройден, время ревью зафиксировано), эмодзи в строке подтверждения BRD заменить на галочку (✅) вместо текущей паузы (⏸️). Пока ждём человека — оставить прежний индикатор ожидания. +- **BR-11.** Русифицировать метки стадий карточки: `Analysis → Анализ`, `Architecture → Архитектура`, `Development → Разработка`, `Review → Код ревью`, `Testing → Тестирование`, `Deploy → Внедрение`. +- **BR-12.** В итоговой (последней) строке готовой задачи заменить технический `deployed` на «Внедрено». + +## 5. Вне scope +- Изменение состава событий, которые шлются ОТДЕЛЬНЫМИ пингами (approve-gate / deploy-fail / agent-fail / error) — остаётся как есть. +- Изменение формата метрик (токены/стоимость/длительность), макета строк, логики «попытка N». +- Любые изменения в Plane-комментариях агентов (`usage.build_status_comment`). +- Хранение истории карточек / несколько карточек на задачу. + +## 6. Влияние на документацию (golden source) +- `CHANGELOG.md` — запись в `[Unreleased]`. +- `docs/architecture/internals.md` (или соответствующая секция про live-tracker) — описать режимы `edit`/`bump` и `ORCH_TRACKER_MODE`. +- `.env.example` — добавить `ORCH_TRACKER_MODE` с пояснением. + +## 7. Критерии успеха (резюме) +Слава может выставить `ORCH_TRACKER_MODE=bump` и видеть актуальную карточку всегда внизу чата, одну на задачу, без звона; при откате на `edit` (дефолт) поведение неотличимо от текущего; текст карточки полностью русифицирован по BR-9..BR-12. Полные условия PASS/FAIL — `03-acceptance-criteria.md`. + + diff --git a/docs/work-items/ORCH-042/02-trz.md b/docs/work-items/ORCH-042/02-trz.md new file mode 100644 index 0000000..23241b8 --- /dev/null +++ b/docs/work-items/ORCH-042/02-trz.md @@ -0,0 +1,118 @@ +# 02 — ТЗ: Telegram live-tracker, режим bump + русификация + +**Work Item:** ORCH-042 · См. `01-brd.md`, `03-acceptance-criteria.md`. + +## 1. Задействованные модули `src/` +| Файл | Что меняется | +|------|--------------| +| `src/config.py` | Новое поле `Settings.tracker_mode` (env `ORCH_TRACKER_MODE`). | +| `src/notifications.py` | Новый helper `delete_telegram(message_id)`; ветвление `update_task_tracker` по режиму; текстовые правки в `_BRD_LABEL`, `_TRACKER_STAGES`, BRD-строке `render_task_tracker`, `_done_link`. | + +БД — **без изменений** (используется существующая колонка `tasks.tracker_message_id` и хелперы `get_tracker_message_id` / `set_tracker_message_id` в `src/db.py`). API HTTP-эндпоинты оркестратора — **без изменений**. Новые QG checks — **не требуются**. + +## 2. Изменения конфигурации (`src/config.py`) + +Добавить в класс `Settings` (рядом с блоком «Telegram notifications»): + +```python +# ORCH-042: режим live-трекера задачи. +# edit -> карточка редактируется на месте (editMessageText), ДЕФОЛТ (как было). +# bump -> при обновлении старое сообщение удаляется и карточка отправляется +# заново вниз чата (deleteMessage + sendMessage + repoint message_id), +# тихо (disable_notification). Одна карточка на задачу в обоих режимах. +# Неизвестное/пустое значение трактуется как edit (см. notifications). +tracker_mode: str = "edit" +``` + +- `env_prefix = "ORCH_"` уже задан → переменная окружения `ORCH_TRACKER_MODE`. +- Резолюция режима — в `notifications`: всё, что не равно (case-insensitive, trimmed) `"bump"`, считается `edit`. Не падать на любом значении. + +## 3. Изменения нотификаций (`src/notifications.py`) + +### 3.1. Новый low-level helper `delete_telegram` +Рядом с `send_telegram` / `edit_telegram`. Контракт «never raises». + +```python +def delete_telegram(message_id: int) -> bool: + """Delete a Telegram message. Never raises. + + Returns True if the message is gone after the call (deleted now, OR Telegram + says it's already not there / can't be deleted -> treat as "no longer our + problem", caller proceeds to send a fresh card). Returns False only on a + transient failure (network / timeout / 5xx / unknown error) where the old + message may still be alive. + """ +``` + +Требования к реализации: +- Эндпоинт `https://api.telegram.org/bot{token}/deleteMessage`, тело `{chat_id, message_id}`, `timeout=5`. +- Нет токена/chat_id → вернуть `False` (как и прочие helpers при отсутствии кредов — ничего не отправлено, ничего не удалено). +- `ok:true` → `True`. +- `ok:false` с описанием «уже нет / нельзя удалить» (маркеры: `"message to delete not found"`, `"message can't be deleted"`, `"message_id_invalid"`) → `True` (сообщение и так недоступно; не транзиент). +- Прочие `ok:false` (неизвестный 400 / 5xx) и исключения (сеть/таймаут) → `False` + `logger.warning`. +- Вынести маркеры в модульную константу (по аналогии с `_GONE_MARKERS`), например `_DELETE_GONE_MARKERS`. + +### 3.2. Ветвление `update_task_tracker` по режиму +Сохранить существующий путь `edit` без изменений поведения. Добавить путь `bump`. + +Псевдокод целевой логики: +```python +def update_task_tracker(task_id: int): + try: + from .db import get_tracker_message_id, set_tracker_message_id + 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: одна карточка, но всегда внизу. + if mid is not None: + delete_telegram(mid) # best-effort; fallback -> всё равно шлём новое + new_mid = send_telegram(text, disable_notification=True) + if new_mid is not None: + set_tracker_message_id(task_id, new_mid) + # send вернул None (нет кредов / транзиент) -> mid не трогаем, + # дубля в пределах вызова нет; перерисуется на следующем переходе. + return + + # mode == "edit" (ДЕФОЛТ): существующая логика без изменений. + ... # текущий код edit/EDIT_GONE-fallback as is + except Exception as e: + logger.warning(f"update_task_tracker({task_id}) failed: {e}") +``` + +Инварианты bump-ветки: +- В пределах ОДНОГО вызова отправляется максимум одно новое сообщение → дублей нет (BR-7). +- `set_tracker_message_id` вызывается ТОЛЬКО при успешном `send` (`new_mid is not None`). При сбое send id остаётся прежним; на следующем переходе старый будет удалён (или уже мёртв) и отправлен новый — без накопления карточек. +- `delete_telegram` — best-effort: его результат НЕ блокирует отправку новой карточки (BR-6: delete-fail → всё равно шлём новое). +- Bump всегда тихий: `disable_notification=True` (BR-4). + +### 3.3. Текстовые правки (общие для обоих режимов) + +| BR | Где | Было | Стало | +|----|-----|------|-------| +| BR-9 | `_BRD_LABEL` (модульная константа) | `"Ревью БРД"` | `"Подтверждение BRD"` | +| BR-10 | `render_task_tracker`, ветка BRD-строки при `review_seconds is not None` | префикс `⏸️` (`⏸️`) | `✅` (`✅`). Ветка ожидания (`review_seconds is None`, с ⏳) — НЕ менять. | +| BR-11 | `_TRACKER_STAGES` (метки) | `Analysis / Architecture / Development / Review / Testing / Deploy` | `Анализ / Архитектура / Разработка / Код ревью / Тестирование / Внедрение` | +| BR-12 | `_done_link` | `"\U0001f4e6 deployed"` | `"\U0001f4e6 Внедрено"` | + +Примечания: +- В `_TRACKER_STAGES` меняется ТОЛЬКО display-label (2-й элемент кортежа). Ключи стадий (`analysis`,…) и имена агентов (`analyst`,…) НЕ трогать — они завязаны на `_STAGE_ACTIVE_AGENT`, `last_done`, БД. +- Выравнивание `{label:<13}` и `{_BRD_LABEL:<13}` оставить как есть (все новые русские метки ≤13 символов; «Подтверждение BRD» длиннее — формат просто не паддит, косметика, поведение не ломает). +- Метки используются и в «✅ …»-строках завершённых стадий, и в «🔄 … идёт»-строке активной стадии — обе автоматически станут русскими (правка в одном месте). + +## 4. Совместимость и риски +- Дефолт `edit` гарантирует нулевую регрессию без явного включения bump (BR-8). Подробно — `10-tech-risks.md` (заводит архитектор/девелопер при необходимости). +- Самохостинг: изменения только в коде нотификаций, миграций БД нет, перезапуск self — по стандартной страховке `deploy-staging` (8501) перед prod (см. `CLAUDE.md`). + +## 5. Артефакты pipeline, которые ДОЛЖНЫ быть обновлены в этом же PR +- `CHANGELOG.md` → запись в `[Unreleased] / Added` (режим bump) + `Changed` (русификация текста). +- `docs/architecture/internals.md` — секция про live-tracker: режимы `edit`/`bump`, `ORCH_TRACKER_MODE`, контракт `delete_telegram`. +- `.env.example` — `ORCH_TRACKER_MODE=edit` с комментарием. +- Тесты — см. `04-test-plan.yaml`. **Существующие тесты в `tests/test_telegram_tracker.py`, проверяющие англоязычные метки (`"✅ Analysis"`, `"🔄 Deploy"`, `"Review"`) и метку `"Ревью БРД"`, ОБЯЗАТЕЛЬНО обновить под новые русские строки** — иначе регрессия в CI. Это правка существующих ассертов, не изменение контракта. + +## 6. Замечания по реализации (без расширения scope) +- Не вводить новых зависимостей; `httpx` уже используется. +- Не менять сигнатуры `send_telegram` / `edit_telegram` / `update_task_tracker` (внешние вызовы из `launcher`/`stage_engine` не трогаются). +- Не менять состав отдельных пингов (approve-gate / error / deploy-fail / agent-fail). + diff --git a/docs/work-items/ORCH-042/03-acceptance-criteria.md b/docs/work-items/ORCH-042/03-acceptance-criteria.md new file mode 100644 index 0000000..a019b34 --- /dev/null +++ b/docs/work-items/ORCH-042/03-acceptance-criteria.md @@ -0,0 +1,55 @@ +# 03 — Критерии приёмки: ORCH-042 + +Каждый критерий — однозначное условие PASS/FAIL. Покрытие тестами — `04-test-plan.yaml`. + +## Конфигурация +- **AC-1.** `Settings.tracker_mode` существует, дефолт `"edit"`, читается из env `ORCH_TRACKER_MODE`. + - PASS: `Settings().tracker_mode == "edit"` без env; `ORCH_TRACKER_MODE=bump` → `"bump"`. + - FAIL: поле отсутствует / другой дефолт / не читает env. +- **AC-2.** Неизвестное/пустое значение режима трактуется как `edit` (оркестратор не падает). + - PASS: `ORCH_TRACKER_MODE=garbage` (или пусто) → `update_task_tracker` идёт по edit-ветке, исключений нет. + - FAIL: исключение / выбор bump-ветки на мусоре. + +## Режим edit (регрессия — поведение как было) +- **AC-3.** Первый вызов (нет `tracker_message_id`): `sendMessage` тихо (`disable_notification=True`), id сохраняется; `editMessageText` НЕ вызывается. +- **AC-4.** Повторный вызов при живом сообщении: `editMessageText` на сохранённый id; новое сообщение НЕ шлётся. +- **AC-5.** `edit` вернул `EDIT_GONE` → шлётся НОВОЕ сообщение, id обновляется (fallback как раньше). +- **AC-6.** `edit` вернул `EDIT_NOT_MODIFIED` или `EDIT_FAILED` → новое сообщение НЕ шлётся, id не меняется (защита от дублей сохранена). + - Все AC-3..AC-6 проверяются при `tracker_mode="edit"` (дефолт). FAIL — любое расхождение с текущим поведением. + +## Режим bump +- **AC-7.** Первый вызов в `bump` (нет id): `deleteMessage` НЕ вызывается; `sendMessage` тихо (`disable_notification=True`); возвращённый id сохраняется. + - PASS: ровно один `send_telegram(..., disable_notification=True)`, `delete_telegram` не вызван, `get_tracker_message_id == new_id`. + - FAIL: вызван delete / громкое сообщение / id не сохранён. +- **AC-8.** Повторный вызов в `bump` при существующем id: вызывается `delete_telegram(старый_id)`, затем `send_telegram(..., disable_notification=True)`, затем `tracker_message_id` перенаправляется на новый id. + - PASS: порядок delete→send соблюдён, id == новый. + - FAIL: нет delete / нет send / id остался старым. +- **AC-9.** Bump тихий: новое сообщение всегда с `disable_notification=True`. + - FAIL: `disable_notification` False/отсутствует. +- **AC-10.** Одна карточка на задачу: за один вызов `update_task_tracker` в bump шлётся НЕ более одного нового сообщения. + - FAIL: более одного `send_telegram` за вызов. + +## Устойчивость +- **AC-11.** Fallback при delete-fail: если `delete_telegram` вернул False (старое >48ч / транзиент) — новое сообщение всё равно отправляется, id обновляется, исключений нет. + - PASS: `delete_telegram→False` → ровно один send → id == новый. + - FAIL: send пропущен / исключение всплыло. +- **AC-12.** `delete_telegram` классификация (httpx замокан, never raises): + - `ok:true` → `True`; + - `ok:false` с `"message to delete not found"` / `"message can't be deleted"` / `"message_id_invalid"` → `True`; + - неизвестный `ok:false` / 5xx → `False`; + - исключение (таймаут/сеть) → `False`; + - нет токена/chat_id → `False`, HTTP-вызов не выполняется. +- **AC-13.** Транзиентный сбой send в bump (send вернул None): `tracker_message_id` НЕ затирается на None; исключений нет; дублей нет (≤1 попытка send за вызов). +- **AC-14.** `update_task_tracker` никогда не выбрасывает исключение ни в одном режиме (контракт «never raises») при любых сбоях БД/сети/Telegram. + +## Текстовые правки (оба режима) +- **AC-15.** Метка «Подтверждение BRD» присутствует в карточке там, где раньше была «Ревью БРД»; строки «Ревью БРД» в выводе нет. +- **AC-16.** После прохождения approve-gate (зафиксированы `brd_review_started_at` и `brd_review_ended_at`) строка подтверждения BRD начинается с ✅ (не ⏸️). Пока ждём человека (`brd_review_ended_at` пуст) — индикатор ожидания/⏳ сохраняется (не ✅). +- **AC-17.** Метки стадий в карточке русские: `Анализ`, `Архитектура`, `Разработка`, `Код ревью`, `Тестирование`, `Внедрение`. Английских меток (`Analysis`/`Architecture`/`Development`/`Review`/`Testing`/`Deploy`) в выводе нет — ни в «✅ …»-строках, ни в «🔄 … идёт». +- **AC-18.** Итоговая строка готовой задачи содержит «📦 Внедрено» (не «deployed»). + +## Регрессия и качество +- **AC-19.** Состав отдельных пингов не изменён: `notify_approve_requested` шлёт ровно один НЕтихий пинг и стартует BRD-часы; `notify_error` — один НЕтихий пинг; `notify_stage_change` / `notify_agent_started` / `notify_qg_failure` — НЕ шлют отдельных сообщений (только refresh трекера). +- **AC-20.** Вся существующая и новая pytest-сюита зелёная (`pytest tests/ -q`). Существующие ассерты в `tests/test_telegram_tracker.py` обновлены под русские метки и «Подтверждение BRD». +- **AC-21.** Документация обновлена в ТОМ ЖЕ PR: `CHANGELOG.md`, `docs/architecture/internals.md` (режимы + `ORCH_TRACKER_MODE` + `delete_telegram`), `.env.example` (`ORCH_TRACKER_MODE`). Отсутствие — REQUEST_CHANGES на ревью. + diff --git a/docs/work-items/ORCH-042/04-test-plan.yaml b/docs/work-items/ORCH-042/04-test-plan.yaml new file mode 100644 index 0000000..c9640be --- /dev/null +++ b/docs/work-items/ORCH-042/04-test-plan.yaml @@ -0,0 +1,160 @@ +work_item: ORCH-042 +description: > + Режим bump live-трекера (delete+send+repoint, тихо, fallback, never-raises), + сохранение режима edit без регрессий, и текстовые правки карточки + (Подтверждение BRD, ✅ после approve, русские метки стадий, «Внедрено»). + Сеть не трогаем: httpx / низкоуровневые helpers мокаются; изолированная temp-БД. + +tests: + # --- config --- + - id: TC-01 + type: unit + description: "Settings.tracker_mode по умолчанию 'edit' и читается из ORCH_TRACKER_MODE (AC-1)" + module: tests/test_config.py + expected: PASS + + - id: TC-02 + type: unit + description: "Неизвестное/пустое значение режима -> update_task_tracker идёт по edit-ветке, без исключений (AC-2)" + module: tests/test_telegram_tracker.py + expected: PASS + + # --- edit mode regression --- + - id: TC-03 + type: unit + description: "edit: первый вызов -> sendMessage тихо, id сохранён, editMessageText не вызван (AC-3)" + module: tests/test_telegram_tracker.py + expected: PASS + + - id: TC-04 + type: unit + description: "edit: повторный вызов -> editMessageText на сохранённый id, нового send нет (AC-4)" + module: tests/test_telegram_tracker.py + expected: PASS + + - id: TC-05 + type: unit + description: "edit: EDIT_GONE -> отправка нового, id обновлён (AC-5)" + module: tests/test_telegram_tracker.py + expected: PASS + + - id: TC-06 + type: unit + description: "edit: EDIT_NOT_MODIFIED и EDIT_FAILED -> нового сообщения нет, id не меняется (AC-6)" + module: tests/test_telegram_tracker.py + expected: PASS + + # --- bump mode --- + - id: TC-07 + type: unit + description: "bump: первый вызов (нет id) -> delete не вызван, send тихий, id сохранён (AC-7, AC-9)" + module: tests/test_tracker_bump.py + expected: PASS + + - id: TC-08 + type: unit + description: "bump: повторный вызов -> delete(старый) затем send(тихо), id перенаправлен на новый, порядок delete->send (AC-8, AC-9, AC-10)" + module: tests/test_tracker_bump.py + expected: PASS + + - id: TC-09 + type: unit + description: "bump fallback: delete_telegram->False -> новое всё равно отправлено, id обновлён, без исключений (AC-11)" + module: tests/test_tracker_bump.py + expected: PASS + + - id: TC-10 + type: unit + description: "bump: send вернул None (транзиент) -> id не затёрт на None, ровно одна попытка send, без исключений (AC-13)" + module: tests/test_tracker_bump.py + expected: PASS + + - id: TC-11 + type: unit + description: "bump: одна карточка за вызов -> send_telegram вызван <=1 раза (AC-10)" + module: tests/test_tracker_bump.py + expected: PASS + + # --- delete_telegram classification --- + - id: TC-12 + type: unit + description: "delete_telegram: ok:true -> True (httpx замокан)" + module: tests/test_tracker_bump.py + expected: PASS + + - id: TC-13 + type: unit + description: "delete_telegram: ok:false 'message to delete not found' / 'message can't be deleted' / 'message_id_invalid' -> True (AC-12)" + module: tests/test_tracker_bump.py + expected: PASS + + - id: TC-14 + type: unit + description: "delete_telegram: неизвестный ok:false / 5xx -> False (AC-12)" + module: tests/test_tracker_bump.py + expected: PASS + + - id: TC-15 + type: unit + description: "delete_telegram: исключение (таймаут/сеть) -> False, never raises (AC-12, AC-14)" + module: tests/test_tracker_bump.py + expected: PASS + + - id: TC-16 + type: unit + description: "delete_telegram: нет токена/chat_id -> False, HTTP не вызывается (AC-12)" + module: tests/test_tracker_bump.py + expected: PASS + + # --- never raises --- + - id: TC-17 + type: unit + description: "update_task_tracker никогда не бросает (DB/сеть сбой) в обоих режимах (AC-14)" + module: tests/test_tracker_bump.py + expected: PASS + + # --- text changes --- + - id: TC-18 + type: unit + description: "render: метка 'Подтверждение BRD' присутствует, 'Ревью БРД' отсутствует (AC-15)" + module: tests/test_telegram_tracker.py + expected: PASS + + - id: TC-19 + type: unit + description: "render: approve-gate пройден (brd_review_ended_at задан) -> строка BRD с ✅, не ⏸️ (AC-16)" + module: tests/test_telegram_tracker.py + expected: PASS + + - id: TC-20 + type: unit + description: "render: ожидание человека (brd_review_ended_at пуст) -> индикатор ожидания/⏳, не ✅ (AC-16)" + module: tests/test_telegram_tracker.py + expected: PASS + + - id: TC-21 + type: unit + description: "render: русские метки стадий (Анализ/Архитектура/Разработка/Код ревью/Тестирование/Внедрение), английских нет — в ✅- и 🔄-строках (AC-17)" + module: tests/test_telegram_tracker.py + expected: PASS + + - id: TC-22 + type: unit + description: "render done: итоговая строка содержит '📦 Внедрено', не 'deployed' (AC-18)" + module: tests/test_telegram_tracker.py + expected: PASS + + # --- separate alerts regression --- + - id: TC-23 + type: unit + description: "Состав отдельных пингов не изменён: approve-gate/error шлют 1 нетихий пинг; stage_change/agent_started/qg_failure не шлют (AC-19)" + module: tests/test_telegram_tracker.py + expected: PASS + + # --- full suite --- + - id: TC-24 + type: integration + description: "Вся pytest-сюита зелёная; обновлённые ассерты под русские метки проходят (AC-20)" + module: tests/ + expected: PASS + diff --git a/docs/work-items/ORCH-042/06-adr/ADR-001-tracker-bump-mode.md b/docs/work-items/ORCH-042/06-adr/ADR-001-tracker-bump-mode.md new file mode 100644 index 0000000..bcd490c --- /dev/null +++ b/docs/work-items/ORCH-042/06-adr/ADR-001-tracker-bump-mode.md @@ -0,0 +1,85 @@ +# ADR-001: Режим bump live-трекера через delete+send+repoint, edit как дефолт + +**Work Item:** ORCH-042 · См. `01-brd.md`, `02-trz.md`, `03-acceptance-criteria.md`, `10-tech-risks.md`. + +## Статус +Accepted + +## Контекст + +Live-tracker (`src/notifications.py`, ветка `feat/telegram-live-tracker`, Variant B+) держит **ОДНУ** карточку на задачу и редактирует её на месте (`editMessageText`) на каждом переходе стадии. Это сознательно убило прежнюю боль — «~15 отдельных карточек/дублей на задачу». Защита от дублей — главный инвариант компонента и не должна регрессировать. + +Побочный эффект edit-режима: при активной переписке в чате карточка «тонет» вверху истории — актуальный статус задачи приходится искать скроллом. Слава просит альтернативу: карточка должна всегда быть последней в чате, но без возврата дублей и без звона на каждой стадии. + +Дополнительно — косметика текста карточки (смесь EN-меток стадий с RU-текстом, «Ревью БРД», технический хвост `deployed`). Текстовые правки тривиальны и сами по себе архитектурного решения не требуют; ключевое решение — как реализовать новый режим, не сломав инвариант «одна карточка». + +Ограничения окружения (см. `CLAUDE.md`, `docs/operations/INFRA.md`): +- Контракт компонента: `update_task_tracker` и low-level helpers **никогда не бросают** (сбой нотификации не должен валить конвейер). +- Self-hosting: правка инструмента, который сейчас в проде и обслуживает другие проекты из общей БД/очереди. Прод-рестарт self — только через `deploy-staging` (8501). +- Telegram Bot API: `deleteMessage` не работает для сообщений старше 48 ч и для уже удалённых/недоступных — это нормальный ожидаемый исход, а не ошибка. + +## Решение + +### Р-1. Поведение задаётся конфиг-флагом, дефолт `edit` (нулевая регрессия) +Новое поле `Settings.tracker_mode` (env `ORCH_TRACKER_MODE`), значения `edit` | `bump`, **дефолт `edit`**. Резолюция режима — в `notifications`, case-insensitive + trim; всё, что не равно `"bump"` (включая пустое/мусор/None), трактуется как `edit`. Без явного включения bump поведение неотличимо от текущего → нулевая регрессия и безопасный фолбэк (оркестратор не падает на любом значении флага). + +### Р-2. Режим bump = delete + send + repoint, инвариант «одна карточка» сохраняется иначе +edit-режим держит одну карточку, *редактируя* её. bump держит одну карточку, *пересоздавая* её внизу: +1. если сохранён `tracker_message_id` — best-effort `delete_telegram(старый_id)`; +2. `send_telegram(text, disable_notification=True)` — новая карточка внизу, тихо; +3. при успехе (`new_mid is not None`) — `set_tracker_message_id` перенаправляется на новый id. + +Итог: в чате всегда ровно одна карточка задачи, и она всегда последняя. За **один** вызов `update_task_tracker` отправляется **не более одного** нового сообщения → дублей в пределах вызова нет. + +### Р-3. delete — best-effort, никогда не блокирует отправку новой карточки +Новый low-level helper `delete_telegram(message_id) -> bool` с контрактом «never raises». Семантика возврата — «исчезло ли старое сообщение»: +- `ok:true` → `True`; +- `ok:false` с маркерами «уже нет / нельзя удалить» (`message to delete not found`, `message can't be deleted`, `message_id_invalid`, вынести в константу `_DELETE_GONE_MARKERS`) → `True` (не транзиент, сообщение и так недоступно); +- прочий `ok:false` / 5xx / исключение (сеть/таймаут) → `False` + `logger.warning`; +- нет токена/chat_id → `False`, HTTP не выполняется. + +**Результат `delete_telegram` НЕ влияет на решение отправлять новую карточку** — её шлём всегда (BR-6: delete-fail у сообщения >48 ч → всё равно новое). `False` означает лишь «старое, возможно, ещё живо»; на следующем переходе оно будет удалено повторно (или уже мёртво). Накопления карточек это не даёт, т.к. указатель всегда хранит ровно один id. + +### Р-4. repoint только при успешном send (анти-затирание указателя) +`set_tracker_message_id` вызывается **только** при `new_mid is not None`. Если send вернул None (нет кредов / транзиент 5xx/таймаут) — id **не трогаем** (не затираем на None): карточка перерисуется на следующем переходе, дубля нет (≤1 попытка send за вызов). Это симметрично существующему edit-fallback, который тоже не плодит сообщения при транзиенте. + +### Р-5. bump всегда тихий +Новая карточка отправляется с `disable_notification=True` — всплывает внизу, но без звука/пинга, как и edit сейчас. Состав отдельных НЕтихих пингов (approve-gate / error / deploy-fail / agent-fail) не меняется (вне scope). + +### Р-6. Текстовые правки — в одной точке, общие для обоих режимов +Правки (`_BRD_LABEL` → «Подтверждение BRD»; ✅ вместо ⏸️ после approve-gate; русские display-labels в `_TRACKER_STAGES`; `_done_link` → «Внедрено») затрагивают только **отображаемые** строки. Ключи стадий (`analysis`, …) и имена агентов (`analyst`, …) НЕ меняются — они завязаны на `_STAGE_ACTIVE_AGENT`, `last_done`, БД. Правка `_TRACKER_STAGES` в одном месте автоматически русифицирует и «✅ …», и «🔄 … идёт». + +### Что НЕ меняется (границы решения) +- БД: миграций нет, используется существующая колонка `tasks.tracker_message_id` и хелперы `get_tracker_message_id` / `set_tracker_message_id`. → `08-data-requirements.md` не требуется. +- Инфраструктура / топология / порты / контейнеры — без изменений. → `07-infra-requirements.md` не требуется. +- State machine (`src/stages.py`), реестр QG (`src/qg/checks.py`), стадии, компоненты — без изменений. → глобальный (cross-cutting) ADR не требуется, решение локально для компонента notifications. +- Сигнатуры `send_telegram` / `edit_telegram` / `update_task_tracker` — без изменений (внешние вызовы из `launcher`/`stage_engine` не трогаются). +- Новых зависимостей нет (`httpx` уже используется). + +## Альтернативы + +- **A1. Только bump, без флага.** Отклонено: ломает обратную совместимость и единственного пользователя (Слава может предпочесть edit); рост риска регрессии защиты от дублей. Флаг с дефолтом `edit` даёт мгновенный откат. +- **A2. Pin-сообщение (закрепить карточку).** Отклонено: pin не решает «карточка внизу при переписке», шлёт системное уведомление о закреплении (звон), и усложняет API-контракт. Вне духа «тихого» трекера. +- **A3. send-then-delete (сначала новое, потом удалить старое).** Отклонено как дефолтный порядок: в окне между send и delete в чате видны ДВЕ карточки; при падении на delete остаётся осиротевшая старая → визуальный дубль. delete-then-send гарантирует ≤1 карточку в любой момент при нормальном пути и ≤1 *новую* отправку за вызов в любом случае. +- **A4. Хранить историю/несколько карточек.** Вне scope и противоречит исходному инварианту «одна карточка». + +## Последствия + +**Плюсы** +- Слава получает актуальную карточку всегда внизу чата, одну на задачу, без звона. +- Нулевая регрессия по умолчанию (edit), мгновенный откат флагом. +- Контракт «never raises» и инвариант «одна карточка» сохранены в обоих режимах. +- Изменения локальны (`config.py` + `notifications.py`), без миграций и без рестарта-критичных зависимостей. + +**Минусы / ограничения** +- bump расходует Telegram API на 2 запроса вместо 1 (delete + send) на переход — для одного получателя несущественно (rate-limit Telegram не угрожает). +- При транзиентном delete-fail возможна кратко осиротевшая старая карточка до следующего перехода (она будет вычищена попыткой delete на следующем апдейте) — приемлемо, дублей всё равно не плодит. +- bump теряет визуальную «эволюцию на месте» edit-режима (история чата получает по карточке-замене) — но в чате всегда одна актуальная, что и требуется. + +**Риски** — см. `10-tech-risks.md`. + +## Связи +- BRD/ТЗ/AC: `docs/work-items/ORCH-042/01-brd.md`, `02-trz.md`, `03-acceptance-criteria.md`; тест-план `04-test-plan.yaml`. +- Компонент: live-tracker (`src/notifications.py`), `feat/telegram-live-tracker` (Variant B+). +- Контекст self-hosting / staging-страховка: `CLAUDE.md`, `docs/operations/INFRA.md`, `docs/architecture/adr/adr-0003-staging-gate.md`. +- Обновляемая дока (в том же PR, стадия development): `CHANGELOG.md`, `docs/architecture/internals.md` (секция live-tracker: режимы + `ORCH_TRACKER_MODE` + `delete_telegram`), `.env.example`. diff --git a/docs/work-items/ORCH-042/10-tech-risks.md b/docs/work-items/ORCH-042/10-tech-risks.md new file mode 100644 index 0000000..a8d67ee --- /dev/null +++ b/docs/work-items/ORCH-042/10-tech-risks.md @@ -0,0 +1,21 @@ +# 10 — Технические риски: ORCH-042 + +См. `02-trz.md`, `06-adr/ADR-001-tracker-bump-mode.md`, `03-acceptance-criteria.md`. + +Шкала: Вероятность × Влияние ∈ {низк., сред., выс.}. + +| # | Риск | Вер. | Влияние | Митигация | Контроль (AC/TC) | +|---|------|------|---------|-----------|-------------------| +| R-1 | **Регрессия защиты от дублей** — рефактор `update_task_tracker` ломает edit-ветку, возвращается боль «~15 карточек». | низк. | выс. | edit — дефолт и неизменяемая ветка; bump добавляется отдельной веткой `if mode == "bump"`, edit-код не трогается. Полное покрытие edit-регрессии тестами. | AC-3..AC-6, AC-8; TC-03..TC-06, TC-24 | +| R-2 | **Двойная отправка / накопление карточек в bump** — delete и send рассинхронизированы, в чате >1 карточки. | низк. | сред. | Инвариант: ≤1 `send_telegram` за вызов; `set_tracker_message_id` только при успешном send; delete best-effort и не блокирует. | AC-8, AC-10, AC-11; TC-08, TC-09, TC-11 | +| R-3 | **Затирание `tracker_message_id` на None** при транзиентном send-fail → потеря указателя, следующий апдейт не найдёт старое. | низк. | сред. | repoint только при `new_mid is not None`; при None id сохраняется как есть. | AC-13; TC-10 | +| R-4 | **Нарушение контракта «never raises»** — исключение из `delete_telegram`/новой ветки валит конвейер (групповой риск из-за общей очереди). | низк. | выс. | `delete_telegram` обёрнут try/except → bool; внешний try/except в `update_task_tracker` сохранён; сеть/httpx мокаются в тестах. | AC-12, AC-14; TC-12..TC-17 | +| R-5 | **Ложная классифик. delete-ответа** — неизвестный `ok:false` принят за «исчезло» (или наоборот), вечные ретраи/тишина. | низк. | низк. | Явные `_DELETE_GONE_MARKERS` → True; всё прочее (включая 5xx) → False; повтор delete на следующем апдейте безопасен (идемпотентно). | AC-12; TC-13, TC-14 | +| R-6 | **Падение CI на старых ассертах** — тесты `tests/test_telegram_tracker.py` проверяют EN-метки/«Ревью БРД». | сред. | сред. | ТЗ §5 явно требует обновить существующие ассерты под русские метки и «Подтверждение BRD» в том же PR. | AC-20; TC-18, TC-21, TC-24 | +| R-7 | **Сломанная human-gate индикация** — ✅ показан до прохождения approve-gate (ввод в заблуждение). | низк. | низк. | ✅ только при заданном `brd_review_ended_at`; ветка ожидания (`review_seconds is None`, ⏳) не меняется. | AC-16; TC-19, TC-20 | +| R-8 | **Скрытая зависимость от display-label** — русификация `_TRACKER_STAGES` ломает логику, завязанную на текст метки. | низк. | сред. | Меняется только 2-й элемент кортежа (label); ключи стадий и имена агентов (`_STAGE_ACTIVE_AGENT`, `last_done`, БД) не трогаются. | AC-17; TC-21 | +| R-9 | **Self-hosting: прод-сбой при выкатке self** — общая БД/очередь, рестарт орка останавливает все проекты. | низк. | выс. | Изменения только в коде нотификаций, миграций БД нет; обязательная страховка `deploy-staging` (8501) перед prod (CLAUDE.md, INFRA.md, adr-0003). Дефолт edit → даже при выкатке поведение не меняется без явного флага. | стадия deploy-staging; `check_staging_status` | +| R-10 | **Документация не обновлена** в том же PR (internals.md / .env.example / CHANGELOG) → REQUEST_CHANGES. | сред. | низк. | ТЗ §5 и AC-21 фиксируют список; reviewer проверяет наличие. | AC-21 | + +## Сводный вывод +Все риски — **низкие по вероятности** при соблюдении инвариантов из ADR-001 (edit-дефолт, ≤1 send/вызов, repoint-only-on-success, never-raises, правка только display-label). Остаточный групповой self-hosting-риск (R-9) полностью покрывается обязательным `deploy-staging`-гейтом и тем, что дефолтное поведение не меняется. Блокеров для перехода на стадию development нет. diff --git a/docs/work-items/ORCH-042/12-review.md b/docs/work-items/ORCH-042/12-review.md new file mode 100644 index 0000000..7a93557 --- /dev/null +++ b/docs/work-items/ORCH-042/12-review.md @@ -0,0 +1,56 @@ +--- +type: review +work_item_id: ORCH-042 +verdict: APPROVED +version: 1 +--- + +# Review ORCH-042 + +## Summary +Telegram live-tracker: добавлен режим `bump` (`ORCH_TRACKER_MODE` / `Settings.tracker_mode`, дефолт `edit`) + русификация и косметика карточки. Реализация точно соответствует `02-trz.md` и `06-adr/ADR-001-tracker-bump-mode.md`. Все 21 критерий приёмки покрыты; `pytest tests/ -q` — **494 passed**. Документация обновлена в том же PR. Замечаний уровня P0/P1/P2 нет. + +## Проверка по осям + +### 1. Соответствие ТЗ +- `Settings.tracker_mode = "edit"` + env `ORCH_TRACKER_MODE` — есть (config.py). +- `delete_telegram(message_id) -> bool` — контракт «never raises», `_DELETE_GONE_MARKERS` вынесены в константу, классификация ok/gone/transient/no-creds реализована дословно по ТЗ §3.1. +- Ветвление `update_task_tracker`: bump = delete(best-effort) → send(silent) → repoint только при `new_mid is not None`; edit-ветка сохранена без изменений (§3.2). Инварианты bump (≤1 send/вызов, анти-затирание указателя, delete не блокирует send, всегда тихо) соблюдены. +- Текстовые правки BR-9..BR-12 (`_BRD_LABEL`→«Подтверждение BRD», ✅ вместо ⏸️ после approve-gate, русские display-labels `_TRACKER_STAGES`, `_done_link`→«Внедрено») — на месте; ключи стадий и имена агентов не тронуты. +- БД, API, сигнатуры helpers, зависимости — без изменений (как и требовалось). + +### 2. Соответствие ADR (ADR-001) +Реализация соответствует решениям Р-1..Р-6: флаг с дефолтом edit (нулевая регрессия), delete+send+repoint, best-effort delete, repoint только при успешном send, всегда тихий bump, текст в одной точке. Выбран порядок delete-then-send (A3 отклонён обоснованно). Глобальные ADR не нарушены; решение локально для компонента notifications, что зафиксировано в ADR. + +### 3. Качество кода +- Defensive-контракты «never raises» соблюдены и в helper, и в `update_task_tracker`. +- Docstrings содержательные; логирование (`debug`/`warning`) корректно разнесено по случаям. +- Security/утечек нет; новых зависимостей нет. + +### 4. Качество тестов +- `tests/test_config.py` (AC-1), `tests/test_tracker_bump.py` (AC-7..AC-14: ордеринг delete→send, delete-fail, send=None, ≤1 send, классификация delete_telegram, never-raises), `tests/test_telegram_tracker.py` (AC-2 garbage→edit, AC-15..AC-18 русификация, регрессия edit). +- Существующие англоязычные ассерты обновлены под русские метки и «Подтверждение BRD» (AC-20). +- Тесты содержательные, не тривиальные. `pytest tests/ -q` → 494 passed. + +## Findings + +### P0 — Blocker +- нет + +### P1 — Must fix +- нет + +### P2 — Should fix +- нет + +### P3 — Nice to have +- [ ] В `_TRACKER_STAGES` строчные комментарии-дубли (`# Анализ` и т.п.) после уже русских меток избыточны — косметика, на поведение не влияет. + +## Документация +Обновлена в том же PR, полностью соответствует AC-21: +- `CHANGELOG.md` — записи в `[Unreleased] / Added` (bump-режим + `delete_telegram`) и `Changed` (русификация). ✅ +- `docs/architecture/internals.md` — новая секция «Live Telegram tracker»: режимы edit/bump (таблица), `ORCH_TRACKER_MODE`, контракт `delete_telegram`, текстовые правки. ✅ +- `.env.example` — `ORCH_TRACKER_MODE=edit` с комментарием. ✅ +- ADR заведён: `06-adr/ADR-001-tracker-bump-mode.md`. ✅ + +Изменения `src/` (config.py, notifications.py) полностью отражены в документации — правило «документация = golden source» выполнено. diff --git a/docs/work-items/ORCH-042/13-test-report.md b/docs/work-items/ORCH-042/13-test-report.md new file mode 100644 index 0000000..bbdac99 --- /dev/null +++ b/docs/work-items/ORCH-042/13-test-report.md @@ -0,0 +1,78 @@ +--- +type: test-report +work_item_id: ORCH-042 +result: PASS +--- + +# Test Report — ORCH-042 + +Telegram live-tracker: режим `bump` (delete+send+repoint, тихо, fallback, never-raises), +сохранение режима `edit` без регрессий, русификация карточки. + +## Окружение +- Python: 3.12.13 +- pytest: 8.3.3 +- Ветка: feature/ORCH-042-telegram-live-tracker-bump +- Дата: 2026-06-06 +- Prod orchestrator (8500): `/health` → `{"status":"ok"}`, активна задача #40 (ORCH-042, stage=testing) + +## Smoke test API +| Endpoint | Результат | +|----------|-----------| +| GET /health | PASS — `{"status":"ok","service":"orchestrator"}` | +| GET /status | PASS — активная задача ORCH-042 (stage=testing) | +| GET /queue | PASS — queued:0 running:1 done:99 failed:0, breaker=closed | + +(`curl` в окружении недоступен — smoke выполнен через `urllib`.) + +## Результаты по тест-плану (04-test-plan.yaml) + +| TC ID | Описание | AC | Результат | +|-------|----------|----|-----------| +| TC-01 | Settings.tracker_mode дефолт 'edit', читается из ORCH_TRACKER_MODE | AC-1 | PASS | +| TC-02 | Мусорное/пустое значение → edit-ветка, без исключений | AC-2 | PASS | +| TC-03 | edit: первый вызов → send тихо, id сохранён, edit не вызван | AC-3 | PASS | +| TC-04 | edit: повтор → editMessageText на сохранённый id, нового send нет | AC-4 | PASS | +| TC-05 | edit: EDIT_GONE → отправка нового, id обновлён | AC-5 | PASS | +| TC-06 | edit: EDIT_NOT_MODIFIED/EDIT_FAILED → нового нет, id не меняется | AC-6 | PASS | +| TC-07 | bump: первый вызов → delete не вызван, send тихий, id сохранён | AC-7,9 | PASS | +| TC-08 | bump: повтор → delete(старый)→send(тихо)→repoint, порядок соблюдён | AC-8,9,10 | PASS | +| TC-09 | bump fallback: delete→False → новое всё равно отправлено | AC-11 | PASS | +| TC-10 | bump: send=None → id не затёрт, ≤1 send | AC-13 | PASS | +| TC-11 | bump: одна карточка за вызов (send ≤1) | AC-10 | PASS | +| TC-12 | delete_telegram: ok:true → True | AC-12 | PASS | +| TC-13 | delete_telegram: gone-маркеры → True | AC-12 | PASS | +| TC-14 | delete_telegram: неизвестный ok:false / 5xx → False | AC-12 | PASS | +| TC-15 | delete_telegram: исключение → False, never raises | AC-12,14 | PASS | +| TC-16 | delete_telegram: нет кредов → False, HTTP не вызван | AC-12 | PASS | +| TC-17 | update_task_tracker never raises (оба режима) | AC-14 | PASS | +| TC-18 | render: «Подтверждение BRD» есть, «Ревью БРД» нет | AC-15 | PASS | +| TC-19 | render: approve-gate пройден → строка BRD с ✅ | AC-16 | PASS | +| TC-20 | render: ожидание человека → ⏳, не ✅ | AC-16 | PASS | +| TC-21 | render: русские метки стадий, английских нет | AC-17 | PASS | +| TC-22 | render done: «📦 Внедрено», не «deployed» | AC-18 | PASS | +| TC-23 | состав отдельных пингов не изменён | AC-19 | PASS | +| TC-24 | вся pytest-сюита зелёная | AC-20 | PASS | + +Все 24 тест-кейса плана покрыты и пройдены. Критерии AC-1..AC-20 подтверждены +тестами; AC-21 (документация) подтверждён на ревью (12-review.md, verdict APPROVED). + +## Вывод pytest + +Целевые модули ORCH-042: +``` +tests/test_config.py tests/test_telegram_tracker.py tests/test_tracker_bump.py +52 passed, 1 warning in 1.38s +``` + +Полный регресс: +``` +======================== 494 passed, 1 warning in 8.57s ======================== +``` + +(Единственный warning — PydanticDeprecatedSince20 в `src/config.py:4`, не связан с +ORCH-042, существовал ранее, на результат не влияет.) + +## Итог +**PASS** — полный регресс 494/494 зелёный, целевые модули 52/52 PASS, smoke API OK. +Задача готова к стадии deploy-staging. diff --git a/docs/work-items/ORCH-042/15-staging-log.md b/docs/work-items/ORCH-042/15-staging-log.md new file mode 100644 index 0000000..ab13597 --- /dev/null +++ b/docs/work-items/ORCH-042/15-staging-log.md @@ -0,0 +1,58 @@ +--- +staging_status: SUCCESS +timestamp: 2026-06-06T10:19:10+00:00 +base_url: http://localhost:8501 +work_item: ORCH-042 +mode: stub +checks: 10/10 PASS +--- + +# Staging Gate Log — ORCH-042 + +Staging test suite completed against the live staging environment +(`orchestrator-staging`, port 8501). All checks passed. + +## Execution + +Canonical procedure (ORCH-048, ADR-001): run **inside** the +`orchestrator-staging` container so the B6 registry-isolation check reads the +registry from the running instance's own process-env (`.env.staging`). + +``` +docker exec orchestrator-staging \ + python3 /repos/orchestrator/scripts/staging_check.py \ + --base-url http://localhost:8501 --mode stub +``` + +(Executed via the Docker Engine API over the mounted unix socket, since no +docker CLI is present in the agent environment; equivalent to the canonical +`docker exec`.) + +**Exit code: 0 → staging_status: SUCCESS** + +## Results — 10/10 PASS + +### Block A — SMOKE +- ✓ A1 GET /health → 200 status=ok +- ✓ A2 GET /queue → 200 with counts/max_concurrency/resilience +- ✓ A3 ORCH_STAGING=true (not prod) + +### Block B — ACCESS +- ✓ B4 Plane: sandbox project accessible (5 projects, sandbox=YES) +- ✓ B5 Gitea: orchestrator-sandbox accessible, push=true +- ✓ B6 Registry: sandbox present, prod ET/ORCH absent (isolation confirmed) + +### Block C — E2E (mode=stub) +- ✓ C7 Create issue in Plane SANDBOX (HTTP 201) +- ✓ C8 Trigger pipeline via /webhook/plane (HTTP 200, HMAC) +- ✓ C9a Branch appears in orchestrator-sandbox +- ✓ C9b Analyst job enqueued in staging queue + +### Cleanup +- ✓ Branch deleted, Plane issue deleted, staging DB job/task rows removed. + +``` +============================================================ + RESULT: 10/10 checks PASS +============================================================ +``` diff --git a/src/config.py b/src/config.py index 3f62b5e..08f6ef3 100644 --- a/src/config.py +++ b/src/config.py @@ -134,6 +134,14 @@ class Settings(BaseSettings): telegram_bot_token: str = "" telegram_chat_id: str = "" + # ORCH-042: режим live-трекера задачи. + # edit -> карточка редактируется на месте (editMessageText), ДЕФОЛТ (как было). + # bump -> при обновлении старое сообщение удаляется и карточка отправляется + # заново вниз чата (deleteMessage + sendMessage + repoint message_id), + # тихо (disable_notification). Одна карточка на задачу в обоих режимах. + # Неизвестное/пустое значение трактуется как edit (см. notifications). + tracker_mode: str = "edit" + class Config: env_prefix = "ORCH_" env_file = ".env" diff --git a/src/notifications.py b/src/notifications.py index 0d1876f..18d01a4 100644 --- a/src/notifications.py +++ b/src/notifications.py @@ -68,6 +68,62 @@ def send_telegram(text: str, disable_notification: bool = False): return None +# Telegram error descriptions that mean a deleteMessage target is already gone / +# can't be deleted (>48h, already deleted, invalid id). Treated as "no longer our +# problem" -> the caller proceeds to send a fresh card. NOT a transient failure. +_DELETE_GONE_MARKERS = ( + "message to delete not found", + "message can't be deleted", + "message_id_invalid", +) + + +def delete_telegram(message_id: int) -> bool: + """Delete a Telegram message. Never raises. + + Returns True if the message is gone after the call (deleted now, OR Telegram + says it's already not there / can't be deleted -> treat as "no longer our + problem", caller proceeds to send a fresh card). Returns False only on a + transient failure (network / timeout / 5xx / unknown error) where the old + message may still be alive. + """ + s = _get_settings() + if not s.telegram_bot_token or not s.telegram_chat_id: + # No creds -> nothing was deleted; mirror the other helpers' no-op path. + return False + try: + url = f"https://api.telegram.org/bot{s.telegram_bot_token}/deleteMessage" + resp = httpx.post( + url, + json={ + "chat_id": s.telegram_chat_id, + "message_id": message_id, + }, + timeout=5, + ) + data = resp.json() + if data.get("ok"): + return True + # ok:false -> classify. "Already gone / can't delete" is an expected, + # non-transient outcome (>48h, already deleted) -> the old message is no + # longer there, caller should still send a fresh card. + desc = str(data.get("description") or "").lower() + if any(m in desc for m in _DELETE_GONE_MARKERS): + logger.debug( + f"delete_telegram(mid={message_id}): already gone ({desc!r})" + ) + return True + # Unknown 400 / 5xx -> transient; the old message may still be alive. + logger.warning( + f"delete_telegram(mid={message_id}): delete failed ({desc!r})" + ) + return False + except Exception as e: + # Network / timeout -> transient; old message may still be alive. + logger.warning(f"delete_telegram(mid={message_id}): transient error: {e}") + return False + + # edit_telegram outcome codes -> let update_task_tracker decide what to do: # "ok" edit applied -> nothing else to do # "not_modified" Telegram says text is identical (400 "message is not @@ -166,19 +222,23 @@ def _get_work_item_id(task_id: int) -> str: # the agent whose agent_runs rows describe that stage's work. "Ревью БРД" is NOT # an agent stage — it is the human approve gate rendered between Analysis and # Architecture from the task's brd_review_* timestamps. +# ORCH-042 (BR-11): display-labels are Russian. Stage KEYS (analysis, …) and +# agent names (analyst, …) are NOT touched — they are wired to +# _STAGE_ACTIVE_AGENT, last_done and the DB. Only the 2nd tuple element changed. _TRACKER_STAGES = [ - ("analysis", "Analysis", "analyst"), - ("architecture", "Architecture", "architect"), - ("development", "Development", "developer"), - ("review", "Review", "reviewer"), - ("testing", "Testing", "tester"), - ("deploy", "Deploy", "deployer"), + ("analysis", "Анализ", "analyst"), # Анализ + ("architecture", "Архитектура", "architect"), # Архитектура + ("development", "Разработка", "developer"), # Разработка + ("review", "Код ревью", "reviewer"), # Код ревью + ("testing", "Тестирование", "tester"), # Тестирование + ("deploy", "Внедрение", "deployer"), # Внедрение ] # Map a pipeline stage -> the agent that is RUNNING while the task sits in it. # (development is entered after architecture finishes, etc.) Used to render the # "🔄 … идёт" line for the currently-active stage. -_BRD_LABEL = "\u0420\u0435\u0432\u044c\u044e \u0411\u0420\u0414" # "Ревью БРД" +# ORCH-042 (BR-9): "Подтверждение BRD" (was "Ревью БРД"). +_BRD_LABEL = "Подтверждение BRD" _STAGE_ACTIVE_AGENT = { "analysis": "analyst", @@ -232,7 +292,8 @@ def render_task_tracker(task_id: int) -> str: the BRD-review timestamps, then renders: - one '✅ · ↓/↑ · · ' line per finished stage (latest run per stage), - - the '⏸️ Ревью БРД · твоё время[ ⏳]' line between Analysis/Architecture, + - the '✅/⏸️ Подтверждение BRD · твоё время[ ⏳]' line between + Analysis/Architecture (✅ once the approve-gate passed, ⏸️+⏳ while waiting), - a '🔄 … идёт' line for the active (in-progress) stage, - the '💰 ↓ / ↑ · ' totals, - on done: '⏱️ Всего .. · агенты .. · твоё ..' and a '🔗 PR / 📦' line. @@ -365,9 +426,11 @@ def render_task_tracker(task_id: int) -> str: if stage_key == "analysis" and brd_started: brd_label = f"{_BRD_LABEL:<13}" if review_seconds is not None: + # ORCH-042 (BR-10): approve-gate passed -> \u2705 (was \u23f8\ufe0f). The + # still-waiting branch below keeps \u23f8\ufe0f + \u23f3 unchanged. dur = _fmt_minutes(review_seconds) lines.append( - f"\u23f8\ufe0f {brd_label} {dur} \u00b7 \u0442\u0432\u043e\u0451 \u0432\u0440\u0435\u043c\u044f" + f"\u2705 {brd_label} {dur} \u00b7 \u0442\u0432\u043e\u0451 \u0432\u0440\u0435\u043c\u044f" ) else: # Still waiting on the human (ended not stamped yet). @@ -406,7 +469,7 @@ def render_task_tracker(task_id: int) -> str: def _done_link(task_id: int, work_item_id) -> str | None: - """Build the final '🔗 PR #n · 📦 deployed' line. Never raises -> None.""" + """Build the final '🔗 PR #n · 📦 Внедрено' line. Never raises -> None.""" try: from .config import settings from .db import get_db @@ -436,7 +499,7 @@ def _done_link(task_id: int, work_item_id) -> str | None: parts = [] if pr_part: parts.append(pr_part) - parts.append("\U0001f4e6 deployed") + parts.append("\U0001f4e6 Внедрено") # ORCH-042 (BR-12): was "deployed" return " \u00b7 ".join(parts) except Exception: return None @@ -445,19 +508,49 @@ def _done_link(task_id: int, work_item_id) -> str | None: def update_task_tracker(task_id: int): """Render + push the live tracker for a task. Never raises. - First call (no stored tracker_message_id): sendMessage (silent) and store the - returned message_id. Subsequent calls: editMessageText the stored message. - A NEW message is sent ONLY when the original is truly gone (deleted / too old - / invalid id). On "not modified" (text unchanged) or transient failures - (network / timeout / 5xx / unknown 400) we do NOT send a new message — that - is exactly what produced duplicate trackers and orphaned (lagging) messages. + Two modes, selected by Settings.tracker_mode (env ORCH_TRACKER_MODE), + resolved case-insensitively here; anything other than "bump" -> "edit" + (ORCH-042). Both keep the "one card per task" invariant. + + edit (DEFAULT): + First call (no stored tracker_message_id): sendMessage (silent) and store + the returned message_id. Subsequent calls: editMessageText the stored + message. A NEW message is sent ONLY when the original is truly gone + (deleted / too old / invalid id). On "not modified" (text unchanged) or + transient failures (network / timeout / 5xx / unknown 400) we do NOT send + a new message — that is exactly what produced duplicate trackers and + orphaned (lagging) messages. + + bump (ORCH-042): + The card is re-created at the BOTTOM of the chat on every update: + best-effort delete_telegram(old_id) (its result NEVER blocks the send), + then sendMessage (silent), then re-point tracker_message_id to the new id + — but ONLY on a successful send (new_mid is not None), so a transient send + failure never wipes the pointer to None. At most ONE new message is sent + per call -> no duplicates within a call. + The tracker is always sent with disable_notification so it never pings — only the dedicated alert helpers ping. """ try: from .db import get_tracker_message_id, set_tracker_message_id 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). + if mid is not None: + # best-effort; result does NOT gate the send (BR-6). + delete_telegram(mid) + new_mid = send_telegram(text, disable_notification=True) + if new_mid is not None: + 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. + return + + # mode == "edit" (DEFAULT): existing behaviour, unchanged. if mid is not None: result = edit_telegram(mid, text) if result in (EDIT_OK, EDIT_NOT_MODIFIED): diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..e18c19e --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,27 @@ +"""ORCH-042: Settings.tracker_mode config field. + +AC-1: tracker_mode defaults to "edit" and is read from env ORCH_TRACKER_MODE. +Settings is a Pydantic BaseSettings reading env at instantiation, so each case +builds a FRESH Settings() (the process-wide singleton is not mutated). +""" + +from src.config import Settings + + +def test_tracker_mode_defaults_to_edit(monkeypatch): + # No env var -> default "edit" (TC-01 / AC-1). + monkeypatch.delenv("ORCH_TRACKER_MODE", raising=False) + assert Settings().tracker_mode == "edit" + + +def test_tracker_mode_reads_env_bump(monkeypatch): + # ORCH_TRACKER_MODE=bump -> "bump" (TC-01 / AC-1). + monkeypatch.setenv("ORCH_TRACKER_MODE", "bump") + assert Settings().tracker_mode == "bump" + + +def test_tracker_mode_reads_env_arbitrary(monkeypatch): + # The field is read verbatim from env; mode RESOLUTION (anything != "bump" + # -> edit) happens in notifications, not here (AC-1/AC-2 split). + monkeypatch.setenv("ORCH_TRACKER_MODE", "garbage") + assert Settings().tracker_mode == "garbage" diff --git a/tests/test_telegram_tracker.py b/tests/test_telegram_tracker.py index d377228..44b9fd6 100644 --- a/tests/test_telegram_tracker.py +++ b/tests/test_telegram_tracker.py @@ -3,7 +3,7 @@ Covers (per DEV_TASK_TELEGRAM_TRACKER.md): * short_model_name: provider/claude- prefix trimming. * render_task_tracker: per-stage line format (in↓/out↑, model, cost, minutes), - the "⏸️ Ревью БРД · твоё время" line, the 💰 totals, and the finish block + the "✅/⏸️ Подтверждение BRD · твоё время" line, the 💰 totals, and the finish block (⏱️ three times + 🔗/📦). * first message -> sendMessage stores message_id; transition -> editMessageText. * fallback: editMessageText fails -> a NEW message is sent and the id updated. @@ -134,17 +134,17 @@ def test_render_in_progress_stage_lines_and_totals(): # Header in-progress assert text.startswith("\U0001f6e0\ufe0f ET-012 \u00b7 \u0422\u0440\u0435\u043a\u0438") # Per-stage format: in↓/out↑ · cost · model - assert "\u2705 Analysis" in text + assert "\u2705 Анализ" in text assert "10\u043c" in text # analysis duration assert "39.6k\u2191" in text # analysis out assert "$2.38" in text assert "opus-4-8" in text assert "sonnet-4.6" in text # reviewer/tester model # BRD review line (human time, ended) - assert "\u0420\u0435\u0432\u044c\u044e \u0411\u0420\u0414" in text + assert "Подтверждение BRD" in text assert "\u0442\u0432\u043e\u0451 \u0432\u0440\u0435\u043c\u044f" in text # Active stage - assert "\U0001f504 Deploy" in text + assert "\U0001f504 Внедрение" in text assert "\u0438\u0434\u0451\u0442" in text # Totals line present with 💰 assert "\U0001f4b0" in text @@ -159,7 +159,7 @@ def test_render_brd_review_waiting_shows_hourglass(): in_tok=1000, out_tok=39600, cache_read=1_100_000, cost=2.38, model="tokenator/claude-opus-4-8") text = N.render_task_tracker(tid) - assert "\u0420\u0435\u0432\u044c\u044e \u0411\u0420\u0414" in text + assert "Подтверждение BRD" in text assert "\u23f3" in text # hourglass while waiting @@ -213,7 +213,7 @@ def test_render_omits_model_when_unknown(): in_tok=10, out_tok=5, cost=0.0, model=None) text = N.render_task_tracker(tid) # No trailing " · " — line ends at cost. - line = [l for l in text.splitlines() if l.startswith("\u2705 Analysis")][0] + line = [l for l in text.splitlines() if l.startswith("\u2705 Анализ")][0] assert line.rstrip().endswith("$0.00") @@ -408,7 +408,7 @@ def test_render_active_stage_shows_attempt_on_second_run(): text = N.render_task_tracker(tid) active = [l for l in text.splitlines() - if l.startswith("\U0001f504") and "Review" in l][0] + if l.startswith("\U0001f504") and "Код ревью" in l][0] assert _POPYTKA in active assert "2" in active assert "\u0438\u0434\u0451\u0442" in active @@ -426,7 +426,7 @@ def test_render_active_stage_no_attempt_on_first_run(): text = N.render_task_tracker(tid) active = [l for l in text.splitlines() - if l.startswith("\U0001f504") and "Review" in l][0] + if l.startswith("\U0001f504") and "Код ревью" in l][0] assert _POPYTKA not in active assert "\u0438\u0434\u0451\u0442" in active @@ -516,3 +516,112 @@ def test_qg_failure_does_not_send_separate_message(monkeypatch): lambda text, disable_notification=False: sent.append(text) or 1) N.notify_qg_failure(tid, "development", "check_ci_green", "CI state: pending") assert sent == [] # QG-pending is log-only, never a separate ping + + +# --------------------------------------------------------------------------- # +# ORCH-042: mode resolution + text changes (edit-mode default) +# --------------------------------------------------------------------------- # +def _brd_line(text): + return [ln for ln in text.splitlines() if "Подтверждение BRD" in ln][0] + + +def test_unknown_mode_falls_back_to_edit_branch(monkeypatch): + """TC-02/AC-2: garbage mode -> edit branch, no exception, no extra send.""" + monkeypatch.setattr(N._get_settings(), "tracker_mode", "garbage", raising=False) + tid = _mk_task(stage="development") + _mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:10:00", + in_tok=10, out_tok=5, cost=0.1) + from src.db import set_tracker_message_id, get_tracker_message_id + set_tracker_message_id(tid, 777) + + edited = {} + monkeypatch.setattr(N, "edit_telegram", + lambda mid, text: edited.update(mid=mid) or N.EDIT_OK) + monkeypatch.setattr(N, "send_telegram", + lambda *a, **k: (_ for _ in ()).throw( + AssertionError("garbage mode must take edit branch"))) + monkeypatch.setattr(N, "delete_telegram", + lambda *a, **k: (_ for _ in ()).throw( + AssertionError("garbage mode must NOT bump-delete"))) + + N.update_task_tracker(tid) # must not raise + assert edited["mid"] == 777 + assert get_tracker_message_id(tid) == 777 # unchanged + + +def test_render_brd_label_is_confirmation_not_review(): + """TC-18/AC-15: 'Подтверждение BRD' present, 'Ревью БРД' absent.""" + tid = _mk_task(stage="architecture", brd_start="2026-06-04 10:00:00", + brd_end="2026-06-04 10:08:00") + _mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:10:00", + in_tok=10, out_tok=5, cost=0.1) + text = N.render_task_tracker(tid) + assert "Подтверждение BRD" in text + assert "Ревью БРД" not in text + + +def test_render_brd_passed_uses_check_not_pause(): + """TC-19/AC-16: approve-gate passed (ended set) -> BRD line starts with ✅.""" + tid = _mk_task(stage="architecture", brd_start="2026-06-04 10:00:00", + brd_end="2026-06-04 10:08:00") + _mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:10:00", + in_tok=10, out_tok=5, cost=0.1) + line = _brd_line(N.render_task_tracker(tid)) + assert line.startswith("✅") # ✅ + assert not line.startswith("⏸") # not ⏸️ + assert "⏳" not in line # no hourglass once passed + + +def test_render_brd_waiting_keeps_pause_and_hourglass(): + """TC-20/AC-16: still waiting (ended empty) -> ⏳ indicator, not ✅.""" + tid = _mk_task(stage="analysis", brd_start="2026-06-04 10:00:00", + brd_end=None) + _mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:10:00", + in_tok=10, out_tok=5, cost=0.1) + line = _brd_line(N.render_task_tracker(tid)) + assert "⏳" in line # ⏳ still waiting + assert not line.startswith("✅") # NOT ✅ yet + + +def test_render_stage_labels_are_russian(): + """TC-21/AC-17: russian stage labels in both ✅- and 🔄-lines; no english.""" + tid = _mk_task(stage="deploy") + _mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:10:00", + in_tok=10, out_tok=5, cost=0.1, model="tokenator/claude-opus-4-8") + _mk_run(tid, "architect", "2026-06-04 09:10:00", "2026-06-04 09:20:00", + in_tok=10, out_tok=5, cost=0.1, model="tokenator/claude-opus-4-8") + _mk_run(tid, "developer", "2026-06-04 09:20:00", "2026-06-04 09:30:00", + in_tok=10, out_tok=5, cost=0.1, model="tokenator/claude-opus-4-8") + _mk_run(tid, "reviewer", "2026-06-04 09:30:00", "2026-06-04 09:35:00", + in_tok=10, out_tok=5, cost=0.1, model="vibecode/claude-sonnet-4.6") + _mk_run(tid, "tester", "2026-06-04 09:35:00", "2026-06-04 09:40:00", + in_tok=10, out_tok=5, cost=0.1, model="vibecode/claude-sonnet-4.6") + _mk_run(tid, "deployer", "2026-06-04 09:40:00", None, + in_tok=0, out_tok=0, exit_code=None) + text = N.render_task_tracker(tid) + for ru in ("Анализ", "Архитектура", "Разработка", "Код ревью", + "Тестирование", "Внедрение"): + assert ru in text, f"missing russian label {ru!r}" + for en in ("Analysis", "Architecture", "Development", "Review", + "Testing", "Deploy"): + assert en not in text, f"english label leaked: {en!r}" + + +def test_render_done_says_vnedreno_not_deployed(): + """TC-22/AC-18: final line says '📦 Внедрено', not 'deployed'.""" + tid = _mk_task(stage="done") + conn = get_db() + conn.execute( + "UPDATE tasks SET created_at='2026-06-04 09:00:00', " + "updated_at='2026-06-04 09:56:00' WHERE id=?", (tid,)) + conn.commit() + conn.close() + _mk_run(tid, "deployer", "2026-06-04 09:50:00", "2026-06-04 09:56:00", + in_tok=400, out_tok=22400, cost=1.73, model="tokenator/claude-opus-4-8") + with patch("src.notifications.httpx") as _hx: + _resp = MagicMock(status_code=200) + _resp.json.return_value = [] # no PR + _hx.get.return_value = _resp + text = N.render_task_tracker(tid) + assert "\U0001f4e6 Внедрено" in text # 📦 Внедрено + assert "deployed" not in text diff --git a/tests/test_tracker_bump.py b/tests/test_tracker_bump.py new file mode 100644 index 0000000..600847a --- /dev/null +++ b/tests/test_tracker_bump.py @@ -0,0 +1,237 @@ +"""ORCH-042: bump-mode live tracker + delete_telegram helper. + +bump = delete(old) + send(new, silent) + repoint message_id. One card per task, +always at the bottom. Covers AC-7..AC-14 (TC-07..TC-17). The edit-mode regression +stays in tests/test_telegram_tracker.py. + +Isolated temp DB; no network (httpx / low-level helpers are patched per case). +""" + +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_tracker_bump.db") +os.environ["ORCH_DB_PATH"] = _test_db + +from unittest.mock import MagicMock, patch # noqa: E402 + +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, +) +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() + yield + if os.path.exists(_test_db): + os.unlink(_test_db) + + +def _mk_task(stage="development", wid="ORCH-042"): + conn = get_db() + cur = conn.execute( + "INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, title) " + "VALUES (?, ?, ?, ?, ?, ?)", + ("p1", wid, "orchestrator", "feature/ORCH-042-x", stage, "bump test"), + ) + tid = cur.lastrowid + conn.commit() + conn.close() + return tid + + +def _bump_mode(monkeypatch): + monkeypatch.setattr(N._get_settings(), "tracker_mode", "bump", raising=False) + + +# --------------------------------------------------------------------------- # +# bump mode behaviour +# --------------------------------------------------------------------------- # +def test_bump_first_call_sends_silent_no_delete(monkeypatch): + """TC-07/AC-7,AC-9: first call (no id) -> NO delete, silent send, id stored.""" + _bump_mode(monkeypatch) + tid = _mk_task(stage="analysis") + + sends = [] + monkeypatch.setattr(N, "send_telegram", + lambda text, disable_notification=False: + sends.append(disable_notification) or 555) + monkeypatch.setattr(N, "delete_telegram", + lambda mid: (_ for _ in ()).throw( + AssertionError("delete must not run on first call"))) + + N.update_task_tracker(tid) + + assert sends == [True] # exactly one silent send + assert get_tracker_message_id(tid) == 555 + + +def test_bump_repeat_deletes_then_sends_and_repoints(monkeypatch): + """TC-08/AC-8,AC-9,AC-10: repeat -> delete(old) THEN send(silent), id repointed.""" + _bump_mode(monkeypatch) + tid = _mk_task() + set_tracker_message_id(tid, 100) + + order = [] + monkeypatch.setattr(N, "delete_telegram", + lambda mid: order.append(("delete", mid)) or True) + monkeypatch.setattr(N, "send_telegram", + lambda text, disable_notification=False: + order.append(("send", disable_notification)) or 200) + + N.update_task_tracker(tid) + + assert order == [("delete", 100), ("send", True)] # delete before send, silent + assert get_tracker_message_id(tid) == 200 # repointed to the new card + + +def test_bump_delete_fail_still_sends(monkeypatch): + """TC-09/AC-11: delete_telegram->False -> new card still sent, id updated.""" + _bump_mode(monkeypatch) + tid = _mk_task() + set_tracker_message_id(tid, 100) + + sends = [] + monkeypatch.setattr(N, "delete_telegram", lambda mid: False) # >48h / transient + monkeypatch.setattr(N, "send_telegram", + lambda text, disable_notification=False: + sends.append(disable_notification) or 201) + + N.update_task_tracker(tid) + + assert sends == [True] # exactly one send despite delete failing + assert get_tracker_message_id(tid) == 201 + + +def test_bump_send_none_keeps_old_id(monkeypatch): + """TC-10/AC-13: send->None (transient) -> id NOT wiped, one send attempt.""" + _bump_mode(monkeypatch) + tid = _mk_task() + set_tracker_message_id(tid, 100) + + sends = [] + monkeypatch.setattr(N, "delete_telegram", lambda mid: True) + 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 (failed) attempt, no retry/dupe + assert get_tracker_message_id(tid) == 100 # pointer preserved, not None + + +def test_bump_one_card_per_call(monkeypatch): + """TC-11/AC-10: at most one send_telegram per update_task_tracker call.""" + _bump_mode(monkeypatch) + tid = _mk_task() + set_tracker_message_id(tid, 100) + + sends = [] + monkeypatch.setattr(N, "delete_telegram", lambda mid: True) + monkeypatch.setattr(N, "send_telegram", + lambda text, disable_notification=False: + sends.append(1) or 202) + + N.update_task_tracker(tid) + assert len(sends) == 1 + + +# --------------------------------------------------------------------------- # +# delete_telegram classification (httpx mocked, never raises) +# --------------------------------------------------------------------------- # +def _del_resp(ok, description=None): + resp = MagicMock() + body = {"ok": ok} + if description is not None: + body["description"] = description + resp.json.return_value = body + return resp + + +def _patch_tg_creds(monkeypatch): + monkeypatch.setattr(N._get_settings(), "telegram_bot_token", "T", raising=False) + monkeypatch.setattr(N._get_settings(), "telegram_chat_id", "C", raising=False) + + +def test_delete_ok_true(monkeypatch): + """TC-12: ok:true -> True.""" + _patch_tg_creds(monkeypatch) + with patch("src.notifications.httpx") as hx: + hx.post.return_value = _del_resp(True) + assert N.delete_telegram(1) is True + + +@pytest.mark.parametrize("desc", [ + "Bad Request: message to delete not found", + "Bad Request: message can't be deleted", + "Bad Request: MESSAGE_ID_INVALID", +]) +def test_delete_gone_markers_are_true(monkeypatch, desc): + """TC-13/AC-12: 'already gone / can't delete' -> True (not transient).""" + _patch_tg_creds(monkeypatch) + with patch("src.notifications.httpx") as hx: + hx.post.return_value = _del_resp(False, desc) + assert N.delete_telegram(1) is True + + +@pytest.mark.parametrize("desc", [ + "Bad Request: some other unexpected error", + "Internal Server Error", +]) +def test_delete_unknown_or_5xx_is_false(monkeypatch, desc): + """TC-14/AC-12: unknown ok:false / 5xx -> False (old may still be alive).""" + _patch_tg_creds(monkeypatch) + with patch("src.notifications.httpx") as hx: + hx.post.return_value = _del_resp(False, desc) + assert N.delete_telegram(1) is False + + +def test_delete_exception_is_false(monkeypatch): + """TC-15/AC-12,AC-14: timeout/network -> False, never raises.""" + _patch_tg_creds(monkeypatch) + with patch("src.notifications.httpx") as hx: + hx.post.side_effect = Exception("read timeout") + assert N.delete_telegram(1) is False + + +def test_delete_no_creds_is_false_and_no_http(monkeypatch): + """TC-16/AC-12: no token/chat_id -> False, HTTP not called.""" + monkeypatch.setattr(N._get_settings(), "telegram_bot_token", "", raising=False) + monkeypatch.setattr(N._get_settings(), "telegram_chat_id", "", raising=False) + with patch("src.notifications.httpx") as hx: + assert N.delete_telegram(1) is False + hx.post.assert_not_called() + + +# --------------------------------------------------------------------------- # +# never raises in either mode +# --------------------------------------------------------------------------- # +def test_update_task_tracker_never_raises_bump(monkeypatch): + """TC-17/AC-14: bump path swallows a render/DB explosion.""" + _bump_mode(monkeypatch) + tid = _mk_task() + monkeypatch.setattr(N, "render_task_tracker", + lambda task_id: (_ for _ in ()).throw(RuntimeError("boom"))) + # Must not raise. + N.update_task_tracker(tid) + + +def test_update_task_tracker_never_raises_edit(monkeypatch): + """TC-17/AC-14: edit path swallows a render/DB explosion.""" + monkeypatch.setattr(N._get_settings(), "tracker_mode", "edit", raising=False) + tid = _mk_task() + monkeypatch.setattr(N, "render_task_tracker", + lambda task_id: (_ for _ in ()).throw(RuntimeError("boom"))) + N.update_task_tracker(tid)