diff --git a/CHANGELOG.md b/CHANGELOG.md index c3fd024..cc58e7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ Формат: [Keep a Changelog](https://keepachangelog.com/). Записи — на смысловой PR/задачу. ## [Unreleased] +- **Подавление Telegram link-preview в карточке трекера / уведомлениях** (ORCH-080): под каждой карточкой трекера (`bump` и `edit`) и под notify/alert-сообщениями Telegram разворачивал баннер «Plane — Modern project management» для кликабельной ссылки `ORCH-NNN` на issue. В дефолтном `bump`-режиме (ORCH-067) карточка пересоздаётся на каждом переходе → баннер дублировался и засорял ленту (жалоба Owner, 08.06). **Корень:** JSON-payload обоих низкоуровневых примитивов `notifications.send_telegram` (`POST /sendMessage`) и `notifications.edit_telegram` (`POST /editMessageText`) не содержал ключ `disable_web_page_preview`. **Фикс (ADR-001, минимальная аддитивная правка на уровне примитива):** добавлен `"disable_web_page_preview": True` в payload обоих методов — гасит баннер у ВСЕХ потребителей (`update_task_tracker` в обоих режимах, `notify_approve_requested`, `notify_error`, alert'ы стадий из `launcher`/`stage_engine`) без изменения их кода. Безусловно, без kill-switch (превью трекера не нужно никому, риск нулевой). `parse_mode: "HTML"` сохранён в обоих payload → ссылка `ORCH-NNN` остаётся кликабельной; `disable_notification` (карточка тихая), bump/edit-логика, инвариант «одна карточка на задачу», контракты возврата (`send_telegram → message_id|None`, `edit_telegram → EDIT_*`) и never-raise — не затронуты. `STAGE_TRANSITIONS`, `QG_CHECKS`, схема БД — без изменений. ADR `docs/work-items/ORCH-080/06-adr/ADR-001-disable-telegram-link-preview.md`. Тесты: `tests/test_link_preview_disabled.py` (TC-01..06: флаг в обоих payload, регрессия `parse_mode`/полей, контракты возврата, never-raise). Документация: `CLAUDE.md` + `docs/architecture/README.md` (компонент Notifications). - **Гарантированный идемпотентный код-PR перед merge-verify (фикс ложного HOLD «no open PR»)** (ORCH-082/ORCH-81): закрыт отсутствующий инвариант «к моменту merge-verify у ветки есть открытый код-PR». **Корень (ORCH-074, 08.06):** PR создавался единственной `launcher._ensure_pr` ТОЛЬКО на developer-пути и ТОЛЬКО при свежем worktree-коммите (`exit==0 → git status непуст → commit → push → agent=="developer"`); после ручных восстановлений `main` у ветки ORCH-074 не оказалось открытого код-PR → детерминированный `merge_gate.merge_pr` вернул `("False", "no open PR")` → защита ORCH-073 верно удержала задачу (HOLD, не ложный `done`), но лечила следствие. **Фикс (ADR-001, аддитивно, внутри того же под-гейта merge-verify, машина стадий не тронута):** (1) новый идемпотентный leaf-актор `merge_gate.ensure_open_pr(repo, branch) -> (status, detail)` (never-raise): `GET …/pulls?state=open` с фильтром **`head.ref==branch` И `base.ref=="main"`** (идентичен `merge_pr`/ORCH-073 FR-3 — авто-docs-PR `base!=main` НЕ код-PR) → `("existed", N)`; иначе `POST …/pulls` → `("created", N)`; гонка `409/422` «PR exists» → повторный GET → `existed` (без дублей); любая иная HTTP/parse/сетевая ошибка → `("failed", reason)`. (2) Врезка в `stage_engine._handle_merge_verify` ПОСЛЕ резолва `validated_revision` и ПЕРЕД `merge_pr`: при `merge_verify_autocreate_pr_enabled` → `ensure_open_pr`; `created|existed` → штатно к `merge_pr` → `verify_merged_to_main`; `failed` → честный HOLD через новый helper `_hold_pr_create_failed` (текст «PR создать не удалось», `result.note="pr-create-failed-hold"` — текстуально отличим от not-merged HOLD; задача остаётся на `deploy`, НЕ `done`, БЕЗ отката на development). (3) `launcher._ensure_pr` делегирован в `merge_gate.ensure_open_pr` (единый код создания PR, общий фильтр `head==branch & base==main`); триггер «создавать только на developer-пути со свежим коммитом» НЕ ужесточён — менялась только реализация под капотом. **Защита ORCH-073 неприкосновенна и приоритетна:** подтверждение merge остаётся ТОЛЬКО `verify_merged_to_main` (SHA-в-main) + `check_main_regression`; `ensure_open_pr` устраняет лишь ЛОЖНЫЙ HOLD «no open PR», реально невлитый код → HOLD как прежде. Kill-switch `ORCH_MERGE_VERIFY_AUTOCREATE_PR_ENABLED` (дефолт `true`); область — `merge_verify_applies(repo)` (self-hosting / `merge_verify_repos`), non-self → no-op; `false` → поведение ORCH-074 1:1. Идемпотентность из Gitea (наличие открытого PR), без миграции БД (restart-safe); `main` не push/force-push. Инварианты НЕ менялись: `STAGE_TRANSITIONS`, реестр `QG_CHECKS` (под-гейт — врезка в `advance_stage`, не новый QG), схема БД, `check_deploy_status`/`_parse_deploy_status`, exit-коды хука, merge-gate (ORCH-043), image-freshness (ORCH-058), внешние HTTP-эндпоинты. ADR `docs/work-items/ORCH-082/06-adr/ADR-001-ensure-open-pr-before-merge-verify.md` (+ сквозной `adr-0016`). Документация: `docs/architecture/README.md` (блок ORCH-082 в merge-verify). Тесты: `tests/test_orch082_ensure_pr.py` (TC-01..05: идемпотентный актор, фильтр base==main, гонка 409/422, never-raise), `tests/test_orch082_merge_verify_autocreate.py` (TC-06..12: врезка, регресс ORCH-073, kill-switch, условность, наблюдаемость). - **Устойчивость резолва `--effort` к пустому env + developer → `xhigh`** (ORCH-081/ORCH-52h): фикс конфигурационного бага, из-за которого в проде `resolve_agent_effort()` возвращал `''` для всех 6 агентов и `--effort` не передавался в Claude CLI (каждый агент бежал на встроенном CLI-дефолте вместо заявленного уровня — прямой удар по предсказуемости качества всего конвейера, включая enduro-trails из общего инстанса). **Корень:** pydantic Settings трактует ПРИСУТСТВУЮЩУЮ env-переменную, даже пустую (`ORCH_AGENT_EFFORT_*=` без значения), как явное `''` и перебивает class-default; в проде пусты И per-agent, И `agent_effort_default`, поэтому у цепочки резолва (`_resolve_agent_attr`: project-override → per-agent env → default → `''`) не остаётся непустого «пола» для отката. **Фикс (вариант c, ADR-001):** в `resolve_agent_effort` (`src/agents/launcher.py`) добавлен уровень 4 — непустой **per-role floor** ниже `default`: новый чистый helper `_agent_effort_floor(agent)` возвращает декларированный class-default поля `agent_effort_` через `type(settings).model_fields[...].default` (значение, которое пустой env перебить НЕ может). Floor срабатывает ТОЛЬКО когда уровни 1–3 пусты и применяется ДО валидации, поэтому: (а) при пустом прод-`.env` каждая роль получает СВОЙ канонический уровень (developer=`xhigh`, tester/deployer=`medium`, analyst/architect/reviewer=`high`), а не общий default; (б) явная опечатка (`turbo`/`ultra`) непуста → floor НЕ применяется → значение штатно дропается валидацией `VALID_EFFORTS` в `''` (never-break ORCH-41 не регрессирует, floor не маскирует мусор); (в) непустой явный env/project-override/`default` по-прежнему ПОБЕЖДАЕТ floor (приоритет резолва сохранён 1:1). Unknown-agent (имя вне 6 ролей) деградирует на class-default `agent_effort_default` (`high`) — безопасный непустой пол. **`config.py`:** `agent_effort_developer` `high → xhigh` (канон Opus 4.8: coding/agentic роль) — единственное изменение значений; floor подтягивает его автоматически (единый источник правды, ноль риска дрейфа floor-карты). Инварианты НЕ менялись: приоритеты/сигнатуры резолва ORCH-41, `_resolve_agent_attr` (общий с model-резолвом, не тронут), `resolve_agent_model` (ORCH-074), путь проброса `--effort` в `_spawn`, `VALID_EFFORTS`, API, схема БД (без миграций). ADR `docs/work-items/ORCH-081/06-adr/ADR-001-effort-resolution-floor.md`. Документация: `docs/architecture/README.md` (таблица «модель/эффорт по ролям»: developer `xhigh` + ремарка про floor), `.env.example` (`ORCH_AGENT_EFFORT_DEVELOPER=xhigh` + комментарий split/floor). Тесты: `tests/test_resolve_agent_effort.py` (TC-01..08: канон-дефолты, floor при пустом env per-role, floor-не-маскирует-typo, приоритет, `xhigh∈VALID_EFFORTS`, сборка флага `--effort xhigh`/`--effort medium`). - **Убран мёртвый frontmatter `model:` + валидация имени модели (never-break)** (ORCH-074): закрыты два дефекта данных/валидации каркаса выбора модели агентов (ORCH-41), без изменения механизма резолва, API или схемы БД. **G1 — мёртвый frontmatter:** из YAML-frontmatter всех 6 промптов `.openclaw/agents/*.md` удалена строка `model:` (`claude-sonnet-4-6` у analyst/developer/tester/deployer, `claude-opus-4-7` у architect/reviewer). launcher НЕ читал frontmatter `model:` — это была лживая/мёртвая декларация, противоречащая реально используемой модели (config) и принципу «документация = golden source»; мина: если бы кто-то «починил» launcher читать frontmatter, все агенты молча уехали бы на устаревшие модели. config (`agent_model_*`/`agent_model_default`) остаётся единственным источником правды; frontmatter описательный. **G2 — валидация имени модели:** добавлен чистый helper `is_valid_model(name)` + `_MODEL_NAME_RE` (`^claude-[a-z0-9.-]+$`) рядом с `VALID_EFFORTS` в `src/agents/launcher.py`. Резолвенное имя модели валидируется ПЕРЕД попаданием в `--model`: невалидное (опечатка, `gpt-4`, пустое, неверный префикс) → `logger.warning` + откат на следующий валидный уровень каскада ORCH-41 (project-override → env → default), в пределе → `""` (без флага `--model`, CLI-дефолт). Никогда не возвращается мусор и не бросается исключение (never-break, поведенческая аналогия `resolve_agent_effort`/`VALID_EFFORTS`). Выбран **формат-чек, а не allowlist `VALID_MODELS`**: allowlist воссоздаёт ровно ту мину, что убивается в G1 (статичный список врёт при устаревании — молча дропнул бы корректную будущую `claude-opus-4-9`); формат-чек forward-compatible (новые `claude-*` проходят без правки кода), финальный авторитет о существовании модели — сам Claude CLI. Тот же предикат применён к inline-чтению `--fallback-model` (`agent_fallback_model` читается напрямую в `_spawn`, мимо `resolve_agent_model` — TRZ §4), поэтому опечатка в `ORCH_AGENT_FALLBACK_MODEL` тоже дропается с warning; для текущего пустого значения регрессии нет. **G4 (fallback) НЕ включён** (`agent_fallback_model=""`, AC-5 N/A) — ради детерминизма (все агенты на `claude-opus-4-8`); **G3 (routing) НЕ включён** (AC-4 N/A) — осознанное решение стейкхолдера (Слава 08.06). Реализация `resolve_agent_model` рефакторнута на генератор кандидатов `_agent_model_candidates` (тот же приоритет ORCH-41) + валидация-со-скипом. Инварианты НЕ менялись: приоритеты/сигнатуры резолва ORCH-41, структура CLI-команды `_spawn`, `VALID_EFFORTS`-гард эффорта, `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, схема БД (без миграций); enduro per-project override валидные имена проходят без изменения поведения. ADR `docs/work-items/ORCH-074/06-adr/ADR-001-model-name-validation.md`. Документация: `docs/architecture/README.md` (таблица «модель/эффорт по ролям» + валидация), `CLAUDE.md`, `.env.example` (блок `ORCH_AGENT_MODEL_*`/`ORCH_AGENT_EFFORT_*`/`ORCH_AGENT_FALLBACK_MODEL`). Тесты: `tests/test_agent_frontmatter_no_model.py` (G1: TC-01/02), `tests/test_resolve_agent_model.py` (G2 never-break: TC-03..09, TC-11 + is_valid_model). diff --git a/CLAUDE.md b/CLAUDE.md index 2afcd8e..75d809b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,6 +54,10 @@ created → analysis → architecture → development → review → testing → - **Кликабельный номер задачи** (`plane_issue_link`) — `ORCH-NNN` в карточке И во всех уведомлениях (`notify_*`, alert'ы стадий) рендерится как `` на issue в Plane; fail-safe → просто `html.escape(номер)`, если ссылку построить нельзя. Никогда не падает. +- **Без link-preview (ORCH-080):** оба примитива (`send_telegram`/`edit_telegram`) шлют + payload с `disable_web_page_preview: True` — баннер Plane («Modern project management») + под кликабельной ссылкой `ORCH-NNN` больше не разворачивается ни в карточке (`bump`/`edit`), + ни в notify/alert-сообщениях. `parse_mode: HTML` сохранён → ссылка остаётся кликабельной. - Транспорт (`send_telegram`/`edit_telegram`/`delete_telegram`), `disable_notification` (карточка тихая, пингуют только alert-хелперы), схема БД — не трогаются. diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 995d13c..32e8d60 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -13,7 +13,7 @@ - **Queue** (`src/queue_worker.py`, ORCH-1) — персистентная очередь задач (SQLite `jobs`), atomic claim, max_concurrency, ретраи, restart-safe. **ORCH-026:** `claim_next_job` гейтит задачи с незавершёнными зависимостями (`job_deps`, `NOT EXISTS`) без занятия слота; декларации/циклы — leaf `src/task_deps.py`. - **Job-reaper** (`src/job_reaper.py`, ORCH-065 — [adr-0011](adr/adr-0011-job-reaper-lease-reclaim.md)) — фоновый daemon-поток (каркас `reconciler`), стартует/останавливается в `main.lifespan` (после `reconciler.start()` / перед `worker.stop()`). Детектирует «мёртвый» `running`-job **без рестарта** процесса (Tier-1 мёртвый `jobs.pid` после `reaper_dead_ticks` тиков; Tier-2 `agent_runs.exit_code` записан, а job ещё `running`; Tier-3 backstop `reaper_max_running_s`) и приводит строку к корректному статусу через те же контракты (`_try_advance_stage`/`_finalize_job`, gate-driven; exit≠0/неизвестно → `attempts str: "message_id": message_id, "text": text, "parse_mode": "HTML", + # ORCH-080: suppress the Plane link-preview banner (see send_telegram). + "disable_web_page_preview": True, }, timeout=5, ) diff --git a/tests/test_link_preview_disabled.py b/tests/test_link_preview_disabled.py new file mode 100644 index 0000000..07f3e7a --- /dev/null +++ b/tests/test_link_preview_disabled.py @@ -0,0 +1,159 @@ +"""ORCH-080 — suppress Telegram link-preview in tracker/notify primitives. + +Both low-level primitives ``send_telegram`` (POST /sendMessage) and +``edit_telegram`` (POST /editMessageText) must add +``"disable_web_page_preview": True`` to their JSON payload, so the Plane +"Modern project management" banner no longer expands under every tracker card / +notification. The clickable issue link must stay clickable -> ``parse_mode: +"HTML"`` is preserved in both payloads, and the never-raise / return contracts +are unchanged. + +Network is isolated: ``src.notifications.httpx`` is patched; creds are stubbed. +Test ids TC-01..TC-06 from 04-test-plan.yaml. +""" + +import os +import tempfile +from unittest.mock import MagicMock, patch + +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_link_preview.db") +os.environ.setdefault("ORCH_DB_PATH", _test_db) + +from src import notifications as N # noqa: E402 + +# conftest._no_telegram autouse-patches src.notifications.send_telegram to a +# no-op for every test (prod-leak guard). Capture the REAL implementation at +# import time (before any fixture runs) so these payload tests can exercise it. +_REAL_SEND = N.send_telegram + + +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 _ok_resp(message_id=42): + resp = MagicMock() + resp.json.return_value = {"ok": True, "result": {"message_id": message_id}} + return resp + + +# --------------------------------------------------------------------------- # +# TC-01 — send_telegram sets disable_web_page_preview: True +# --------------------------------------------------------------------------- # +def test_send_telegram_disables_link_preview(monkeypatch): + _patch_tg_creds(monkeypatch) + with patch("src.notifications.httpx") as hx: + hx.post.return_value = _ok_resp() + _REAL_SEND("hello") + payload = hx.post.call_args.kwargs["json"] + assert payload["disable_web_page_preview"] is True + + +# --------------------------------------------------------------------------- # +# TC-02 — edit_telegram sets disable_web_page_preview: True +# --------------------------------------------------------------------------- # +def test_edit_telegram_disables_link_preview(monkeypatch): + _patch_tg_creds(monkeypatch) + with patch("src.notifications.httpx") as hx: + hx.post.return_value = _ok_resp() + N.edit_telegram(1, "hello") + payload = hx.post.call_args.kwargs["json"] + assert payload["disable_web_page_preview"] is True + + +# --------------------------------------------------------------------------- # +# TC-03 — parse_mode HTML preserved in both payloads (clickable ) +# --------------------------------------------------------------------------- # +def test_send_telegram_keeps_parse_mode_html(monkeypatch): + _patch_tg_creds(monkeypatch) + with patch("src.notifications.httpx") as hx: + hx.post.return_value = _ok_resp() + _REAL_SEND("hello") + assert hx.post.call_args.kwargs["json"]["parse_mode"] == "HTML" + + +def test_edit_telegram_keeps_parse_mode_html(monkeypatch): + _patch_tg_creds(monkeypatch) + with patch("src.notifications.httpx") as hx: + hx.post.return_value = _ok_resp() + N.edit_telegram(1, "hello") + assert hx.post.call_args.kwargs["json"]["parse_mode"] == "HTML" + + +# --------------------------------------------------------------------------- # +# TC-04 — send_telegram preserves existing fields + disable_notification arg +# --------------------------------------------------------------------------- # +def test_send_telegram_preserves_existing_fields(monkeypatch): + _patch_tg_creds(monkeypatch) + with patch("src.notifications.httpx") as hx: + hx.post.return_value = _ok_resp() + _REAL_SEND("body", disable_notification=True) + payload = hx.post.call_args.kwargs["json"] + assert payload["chat_id"] == "C" + assert payload["text"] == "body" + assert payload["parse_mode"] == "HTML" + assert payload["disable_notification"] is True + + +def test_send_telegram_disable_notification_default_false(monkeypatch): + _patch_tg_creds(monkeypatch) + with patch("src.notifications.httpx") as hx: + hx.post.return_value = _ok_resp() + _REAL_SEND("body") + assert hx.post.call_args.kwargs["json"]["disable_notification"] is False + + +def test_edit_telegram_preserves_existing_fields(monkeypatch): + _patch_tg_creds(monkeypatch) + with patch("src.notifications.httpx") as hx: + hx.post.return_value = _ok_resp() + N.edit_telegram(7, "body") + payload = hx.post.call_args.kwargs["json"] + assert payload["chat_id"] == "C" + assert payload["message_id"] == 7 + assert payload["text"] == "body" + assert payload["parse_mode"] == "HTML" + + +# --------------------------------------------------------------------------- # +# TC-05 — return contracts unchanged +# --------------------------------------------------------------------------- # +def test_send_telegram_returns_message_id(monkeypatch): + _patch_tg_creds(monkeypatch) + with patch("src.notifications.httpx") as hx: + hx.post.return_value = _ok_resp(message_id=99) + assert _REAL_SEND("x") == 99 + + +def test_send_telegram_returns_none_without_creds(monkeypatch): + monkeypatch.setattr(N._get_settings(), "telegram_bot_token", "", raising=False) + monkeypatch.setattr(N._get_settings(), "telegram_chat_id", "", raising=False) + assert _REAL_SEND("x") is None + + +def test_edit_telegram_returns_edit_ok(monkeypatch): + _patch_tg_creds(monkeypatch) + with patch("src.notifications.httpx") as hx: + hx.post.return_value = _ok_resp() + assert N.edit_telegram(1, "x") == N.EDIT_OK + + +# --------------------------------------------------------------------------- # +# TC-06 — never-raise: httpx.post raising -> None / EDIT_FAILED +# --------------------------------------------------------------------------- # +def test_send_telegram_never_raises(monkeypatch): + _patch_tg_creds(monkeypatch) + with patch("src.notifications.httpx") as hx: + hx.post.side_effect = Exception("boom") + assert _REAL_SEND("x") is None + + +def test_edit_telegram_never_raises(monkeypatch): + _patch_tg_creds(monkeypatch) + with patch("src.notifications.httpx") as hx: + hx.post.side_effect = Exception("boom") + assert N.edit_telegram(1, "x") == N.EDIT_FAILED