feat(notifications): add bump mode + russify Telegram live-tracker
All checks were successful
CI / test (push) Successful in 13s
CI / test (pull_request) Successful in 13s

ORCH-042: new ORCH_TRACKER_MODE (Settings.tracker_mode, default edit) selects
the live-tracker card behaviour. bump mode re-creates the card at the bottom of
the chat on every update (delete_telegram + send silently + repoint message_id),
keeping the "one card per task" invariant: <=1 new message per call, repoint
only on successful send, delete result never gates the send. New never-raising
delete_telegram helper. Anything != "bump" resolves to edit (zero regression).

Also russify/cosmetic-fix the card text (both modes): "Подтверждение BRD" label,
 after approve-gate, Russian stage labels, "📦 Внедрено". Docs updated in the
same PR (CHANGELOG, internals.md, .env.example).

Refs: ORCH-042

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 10:13:49 +00:00
parent 0ac50b8c73
commit 05c17135c1
8 changed files with 527 additions and 25 deletions

View File

@@ -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

View File

@@ -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 <T>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-`<a>`-ссылки — на `docs/work-items/<WI>/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(...)` теперь возвращает `<li><a>…</a></li>` 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`).

View File

@@ -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

View File

@@ -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"

View File

@@ -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
# "🔄 <Stage> … идёт" 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 '✅ <Stage> <dur> · <in>↓/<out>↑ · <cost> · <model>' line per finished
stage (latest run per stage),
- the '⏸️ Ревью БРД <dur> · твоё время[ ⏳]' line between Analysis/Architecture,
- the '✅/⏸️ Подтверждение BRD <dur> · твоё время[ ⏳]' line between
Analysis/Architecture (✅ once the approve-gate passed, ⏸️+⏳ while waiting),
- a '🔄 <Stage> … идёт' line for the active (in-progress) stage,
- the '💰 <in>↓ / <out>↑ · <cost>' 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):

27
tests/test_config.py Normal file
View File

@@ -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"

View File

@@ -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 " · <model>" — 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

237
tests/test_tracker_bump.py Normal file
View File

@@ -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)