Compare commits

..

1 Commits

Author SHA1 Message Date
dev-agent
5f93cba297 fix(tests): per-project Plane states in webhook tests + close CI hole (ORCH-39)
All checks were successful
CI / test (push) Successful in 11s
CI / test (pull_request) Successful in 10s
After ORCH-10 the webhook resolves Plane state UUIDs per-project via
get_project_states(project_id). The m6/plane webhook tests hardcoded the
enduro in_progress UUID for ORCH-project payloads, so the pipeline never
started and task creation assertions failed.

Tests:
- Mock get_project_states with a deterministic per-project ET/ORCH map (no
  network) and reset _STATES_CACHE via reload_project_states() per test.
- Send each request with the in_progress UUID that matches its own project.

CI hole:
- requirements.txt lacked pytest-asyncio, so the 6 @pytest.mark.asyncio tests
  in test_orch10_states.py were SILENTLY SKIPPED -> CI green while async paths
  never ran. Add pytest-asyncio + pytest.ini (asyncio_mode=auto, strict
  markers); harden ci.yml (set -euo pipefail, --strict-markers) so any failure
  or unknown marker reds the build and the whole suite runs.

src/ unchanged (per-project resolving is the ORCH-10 feature, kept as-is).
2026-06-05 16:49:18 +03:00
46 changed files with 119 additions and 5031 deletions

View File

@@ -1,8 +1,4 @@
ORCH_PLANE_API_URL=http://plane-app-api-1:8000
# External (browser) web URL of Plane for clickable issue links in notifications
# (ORCH-017). Falls back to ORCH_PLANE_API_URL; a loopback fallback is treated as
# "no web URL" and the Plane link is omitted. Example: https://plane.example.org
ORCH_PLANE_WEB_URL=
ORCH_PLANE_API_TOKEN=
ORCH_PLANE_WORKSPACE_SLUG=
ORCH_PLANE_WEBHOOK_SECRET=

View File

@@ -5,9 +5,6 @@
## [Unreleased]
### Added
- **Прямые ссылки на 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`.
- **Конфигурируемые модель LLM и режим работы (`--effort`) агентов** (ORCH-41): модель/effort каждого агента вынесены из хардкода `launcher.py` в конфиг — глобально per-agent (`ORCH_AGENT_MODEL_<AGENT>` / `ORCH_AGENT_EFFORT_<AGENT>`, дефолты `ORCH_AGENT_MODEL_DEFAULT=claude-opus-4-8`, `ORCH_AGENT_EFFORT_DEFAULT=high`) и per-project (`agent_models` / `agent_efforts` в `ORCH_PROJECTS_JSON`). Резолверы `resolve_agent_model` / `resolve_agent_effort` (приоритет project > per-agent env > default > пусто), валидация effort `{low,medium,high,xhigh,max}`, опц. `ORCH_AGENT_FALLBACK_MODEL` (`--fallback-model`). Хардкод `"model":"opus"` (architect/reviewer) удалён. Тесты: `test_resolve_agent_model.py`, `test_resolve_agent_effort.py`.
- **Единый status-коммент агентов в Plane** (ORCH-016): `usage.build_status_comment(...)` — один хелпер для ВСЕХ ролей (analyst..deployer). HTML-формат: header `{icon} {Role} — {описание}`, опциональная строка `Verdict/Status: …` из YAML-frontmatter артефакта, **строка `Длительность: 4m 12s`** (явный `duration_s` от launcher, fallback из `agent_runs` для аналитика), `<b>Документы:</b><ul><li><a>…</a></li></ul>`, тех-хвост `<sub>tokens · cost</sub>`. Утилитки: `usage.fmt_duration`, `usage.get_agent_duration`, новый модуль `src/frontmatter.py` (defensive YAML reader). ADR `docs/work-items/ORCH-016/06-adr/ADR-001-unified-status-comment.md`.
- **Документация по канону** (ORCH-9): `CLAUDE.md` (паспорт проекта), структура `docs/` (`architecture/` + `adr/`, `operations/`, `work-items/`, `history/`), `docs/operations/INFRA.md` (RUNBOOK с инфра-изоляцией и self-hosting рисками).
- **ADR**: adr-0001 (multi-repo registry), adr-0002 (job queue), adr-0003 (условный staging-гейт).
- **Стадия `deploy-staging`** (ORCH-35): промежуточный гейт между `testing` и `deploy`. QG `check_staging_status` (условный, только для self-hosting repo). PR #31.
@@ -17,7 +14,6 @@
- **Реестр проектов** (ORCH-6): `src/projects.py`, фильтрация вебхуков по проекту.
### Changed
- **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`).
### Fixed

View File

@@ -46,21 +46,6 @@ created → analysis → architecture → development → review → testing →
- Deploy / deploy-staging FAILED → откат на `development`.
- `get_previous_stage` использует порядок ключей `STAGE_TRANSITIONS`.
### Plane Sync: единый status-коммент агентов (ORCH-016)
Все агенты (analyst / architect / developer / reviewer / tester / deployer) пишут финальный коммент через **один хелпер** `usage.build_status_comment(...)` (ADR `docs/work-items/ORCH-016/06-adr/ADR-001-unified-status-comment.md`). Формат HTML, разделители `<br>`:
```
{ICON} {RoleName} — {описание стадии}
[Verdict|Status: VALUE] # reviewer/tester/deployer, из YAML-frontmatter артефакта
[Длительность: 4m 12s] # явный duration_s от launcher, либо fallback из agent_runs
<b>Документы:</b><ul><li><a href="…">label</a></li>…</ul>
[<sub>8.5M in / 45.8k out · $7.29</sub>] # тех-хвост usage; опускается при нулях
```
- **Длительность** считается launcher'ом (`_monitor_agent`) и пробрасывается в `_post_usage_comments`; для analyst (коммент строится в `stage_engine`) используется DB-фоллбэк `usage.get_agent_duration(task_id, agent)`.
- **Vердикт-парсер** — `src/frontmatter.read_frontmatter_value(...)` (defensive, никогда не raise). Машинные ключи: `verdict:` (reviewer/tester), `deploy_status:` (14-deploy-log.md), `staging_status:` (15-staging-log.md).
- Формат коммента **не** меняет реестр гейтов и стадий; коммент — отображение, не управление.
## База данных (SQLite)
- `events` — входящие вебхуки (дедуп)
- `tasks` — задачи и их стадии

View File

@@ -326,10 +326,6 @@ jobs со статусом `running` (воркер умёр на рестарт
- `ORCH_MAX_CONCURRENCY` (default 1) — лимит параллельных jobs.
- `ORCH_QUEUE_POLL_INTERVAL` (default 2.0) — период опроса.
- `ORCH_AGENT_MODEL_DEFAULT` / `ORCH_AGENT_MODEL_<AGENT>` (ORCH-41) — модель агентов; дефолт `claude-opus-4-8`.
- `ORCH_AGENT_EFFORT_DEFAULT` / `ORCH_AGENT_EFFORT_<AGENT>` (ORCH-41) — режим `--effort` (low|medium|high|xhigh|max).
- `ORCH_AGENT_FALLBACK_MODEL` (ORCH-41) — опц. `--fallback-model` при overloaded.
- per-project override: `agent_models` / `agent_efforts` в `ORCH_PROJECTS_JSON`; резолверы `resolve_agent_model` / `resolve_agent_effort` (project > per-agent env > default > пусто).
Наблюдаемость: `GET /queue` — counts по статусам + последние 10 jobs.

View File

@@ -42,18 +42,12 @@
| Переменная | Назначение |
|-----------|-----------|
| `ORCH_PLANE_API_URL` / `_TOKEN` / `_WORKSPACE_SLUG` | доступ к Plane API |
| `ORCH_PLANE_WEB_URL` | внешний (браузерный) web-URL Plane для кликабельных ссылок на issue в уведомлениях (ORCH-017); пусто → фолбэк на `ORCH_PLANE_API_URL`, loopback-фолбэк → ссылка опускается |
| `ORCH_PLANE_WEBHOOK_SECRET` | HMAC-проверка вебхуков Plane |
| `ORCH_GITEA_URL` / `_TOKEN` / `_WEBHOOK_SECRET` | доступ к Gitea + HMAC |
| `ORCH_CLAUDE_BIN` | путь к claude CLI |
| `ORCH_REPOS_DIR` / `ORCH_HOST_REPOS_DIR` | каталог репозиториев (в контейнере / на хосте) |
| `ORCH_DB_PATH` | путь к SQLite БД |
| `ORCH_PROJECTS_JSON` | реестр проектов (Plane id → repo + prefix); пусто → дефолт из `src/projects.py` |
| `ORCH_AGENT_MODEL_DEFAULT` | LLM-модель агентов по умолчанию (ORCH-41); дефолт `claude-opus-4-8` |
| `ORCH_AGENT_MODEL_<AGENT>` | per-agent модель (ANALYST/ARCHITECT/DEVELOPER/REVIEWER/TESTER/DEPLOYER); пусто → default |
| `ORCH_AGENT_EFFORT_DEFAULT` | режим работы `--effort` по умолчанию (ORCH-41): low\|medium\|high\|xhigh\|max; дефолт `high` |
| `ORCH_AGENT_EFFORT_<AGENT>` | per-agent effort; дефолт: думающие → high, tester/deployer → medium |
| `ORCH_AGENT_FALLBACK_MODEL` | опц. фолбэк-модель при overloaded (`--fallback-model`); пусто → без флага |
| `DEPLOY_SSH_USER` / `_HOST` / `DEPLOY_HOOK_SCRIPT` | параметры деплой-хука |
**Секреты — только в `.env` / `.env.staging` на хосте, в гит НЕ коммитятся.** Канон — `.env.example`, `.env.staging.example`.
@@ -61,26 +55,6 @@
## Реестр проектов (`src/projects.py`, ORCH-6)
Связывает Plane project id → gitea repo + work-item prefix. Источник: `ORCH_PROJECTS_JSON`, fallback — встроенный дефолт. Прод видит: `enduro-trails` (ET), `orchestrator` (ORCH). Staging видит ТОЛЬКО `orchestrator-sandbox` (SANDBOX) — изоляция.
## Модель и effort агентов (`src/config.py` + `src/agents/launcher.py`, ORCH-41)
Модель LLM и режим работы (`--effort`) каждого агента **конфигурируемы** — глобально per-agent (env) и per-project (через `ORCH_PROJECTS_JSON`).
**Приоритет резолвинга** (`resolve_agent_model` / `resolve_agent_effort`):
1. per-project override — `agent_models` / `agent_efforts` в записи `ORCH_PROJECTS_JSON`;
2. per-agent env — `ORCH_AGENT_MODEL_<AGENT>` / `ORCH_AGENT_EFFORT_<AGENT>` (если непусто);
3. глобальный дефолт — `ORCH_AGENT_MODEL_DEFAULT` (`claude-opus-4-8`) / `ORCH_AGENT_EFFORT_DEFAULT` (`high`);
4. пусто → флаг не передаётся, действует дефолт CLI.
**Значения effort:** `low` < `medium` < `high` < `xhigh` < `max` — рычаг «качество vs стоимость/время». Дефолтная раскладка: думающие агенты (analyst/architect/developer/reviewer) → `high`, механические (tester/deployer) → `medium`. Невалидное значение → лог-warning, флаг опускается.
**Per-project override в `ORCH_PROJECTS_JSON`** (поля `agent_models` / `agent_efforts` опциональны, старые записи работают):
```json
{"plane_project_id":"...","repo":"orchestrator","work_item_prefix":"ORCH",
"agent_models":{"developer":"claude-opus-4-8","reviewer":"claude-sonnet-4-6"},
"agent_efforts":{"developer":"xhigh","tester":"low"}}
```
> ⚠️ Бюджет (ORCH-38): `claude-opus-4-8` дефолт в коде; реальное переключение прод-env делается отдельно после согласования.
## ⚠️ Self-hosting — оркестратор дорабатывает САМ СЕБЯ
**Факт:** прод-инстанс `orchestrator` (8500) — ОДИН на ВСЕ прод-проекты (enduro-trails + orchestrator), с ОБЩЕЙ БД `./data/orchestrator.db` и общей очередью задач (ORCH-1).

View File

@@ -1,7 +0,0 @@
# Business Request: Единообразные коммент-артефакты в Plane от всех агентов
Work Item ID: ORCH-016
## Description
TBD

View File

@@ -1,85 +0,0 @@
# BRD: Единообразные коммент-артефакты в Plane от всех агентов
Work Item ID: **ORCH-016**
Стадия: analysis
Автор: analyst
Дата: 2026-06-05
Ревизия: 2 (учтён фидбэк стейкхолдера от 2026-06-05 — добавить длительность работы агента в коммент)
---
## 1. Бизнес-цель
Стейкхолдер (Слава) должен мочь из ленты комментариев задачи в Plane **за один клик** перейти к артефакту любого агента (ADR, PR, ревью, отчёт тестера, деплой-лог), а не разбирать «шумные» строки без удобной ссылки и человекочитаемого описания.
Помимо ссылок, по комментариям стейкхолдер хочет **видеть, сколько работал каждый агент** (длительность стадии), не открывая БД оркестратора и не лезя в `agent_runs`.
## 2. Мотивация
Сейчас в Plane комменты двух разных стилей:
| Кто пишет | Формат коммента | Источник |
|-----------|-----------------|----------|
| **Аналитик (эталон)** | HTML: человеческое описание стадии + `<ul>` со списком ссылок на артефакты, заголовок «Документы:» | `src/stage_engine.py::_build_analyst_ready_comment` (PR #13) |
| Architect / Developer / Reviewer / Tester / Deployer | Однострочник «{icon} Role готов · 8.5M in / 45.8k out · $7.29» + markdown-ссылки следом | `src/usage.py::usage_comment` + `artifact_links` |
Проблемы второго формата:
1. Нет человеческого описания результата стадии — есть только техническая метрика «tokens/cost».
2. Нет краткого вердикта одной строкой там, где он есть в артефакте (Reviewer `APPROVE/REQUEST_CHANGES`, Tester `PASS/FAIL`, Deployer `SUCCESS/FAILED`).
3. Формат разнится по агентам (где-то «📂 Branch + 🔗 PR», где-то «📄 Test report») — нет единого визуального якоря.
4. **Не видно длительности стадии** — стейкхолдер не понимает, агент отработал за 30 секунд или за 12 минут; это важная метрика для оценки SLA, поведения долгих стадий (testing/deploy) и подозрений на «зависание».
## 3. Целевая аудитория
- **Стейкхолдер задачи (Слава, владелец продукта)** — главный потребитель ленты комментариев в Plane.
- **Reviewer / QA / DevOps по другим проектам (enduro-trails)** — те же ссылки помогут им навигироваться по задачам, не открывая БД оркестратора.
## 4. Scope (что входит)
1. Привести коммент-формат **architect, developer, reviewer, tester, deployer** к единому виду по эталону аналитика:
- заголовок-роль (emoji + имя роли),
- короткое человеческое описание результата стадии (1 предложение),
- кликабельная ссылка(и) на СВОЙ артефакт,
- **одна строка-вердикт** там, где это уместно (Reviewer / Tester / Deployer),
- **одна строка-длительность** работы агента — для всех ролей, включая аналитика.
2. Переиспользовать `settings.gitea_public_url` для кликабельных ссылок (готово в PR #14).
3. Сохранить существующее поведение аналитика (PR #13) — он уже соответствует целевому формату; в идеале — переиспользовать общий хелпер. К аналитику также добавляется строка длительности.
4. Один коммент на агента за прохождение стадии (без спама).
5. Источник длительности — уже существующая метрика `_duration_s` в `src/agents/launcher.py` (или `agent_runs.started_at` / `finished_at`). Новых таблиц/полей в БД не заводим.
## 5. Out of scope (что НЕ трогаем)
- Логика Quality Gates (`src/qg/checks.py`).
- Status-only verdict model (PR #12) — приёмка аналитика через смену статуса Plane на «Approved/Rejected».
- Дедупликация вебхуков (`src/webhooks/_dedup.py`).
- `set_issue_done`, `notify_done`, `notify_qg_failure` — внутренние нотификации остаются как есть.
- Per-agent bot-авторство (PR с `PLANE_BOT_TOKENS`) — сохраняется.
- Изменение схемы БД, конвейера стадий, реестра QG.
## 6. Бизнес-требования
**BR-1.** Каждый агент по завершении своей стадии (вне пути ошибки) пишет в Plane **ровно один** коммент в едином формате.
**BR-2.** Коммент содержит:
- заголовок с emoji-иконкой роли и человекочитаемым названием,
- 12 предложения с описанием результата стадии на русском языке,
- кликабельную ссылку (-и) на артефакт(ы) этого агента в Gitea,
- одну строку вердикта (Verdict / Status), если артефакт его содержит,
- **одну строку длительности работы агента** (`Длительность: <human-format>`), всегда, если значение известно.
**BR-3.** Ссылки строятся через `gitea_public_url` (fallback на `gitea_url`).
**BR-4.** Формат должен быть устойчив: отсутствующий артефакт / отсутствующий вердикт / неизвестная длительность не ломают коммент — соответствующая строка просто опускается.
**BR-5.** Изменение **не нарушает**:
- status-only verdict model (аналитик по-прежнему ждёт смены статуса Plane),
- дедуп комментов и вебхуков,
- работу `set_issue_done` / `notify_done` на финале конвейера,
- per-agent bot-авторство.
**BR-6.** Длительность отображается в человекочитаемой форме (`12s`, `4m 12s`, `1h 03m`), а не в виде голых секунд. Источник — `agent_runs.started_at` / `finished_at` (или уже посчитанный `_duration_s` в `launcher.py`). Новых полей в БД не вводится.
## 7. Ограничения и риски
- **Self-hosting:** оркестратор правит сам себя; деплой только через staging-гейт (порт 8501) → прод-контейнер `orchestrator` не перезапускать в рамках задачи.
- Прод обслуживает другие проекты (enduro-trails) — нельзя сломать комменты в их задачах.
- Plane Bot-авторство (`_headers_for`) должно остаться — коммент пишется под бот-токеном своей роли.
- Reviewer/tester вердикты читаются из артефактов; нужно идемпотентно работать, если артефакт ещё не закоммичен / не доступен в worktree.
## 8. Связки
- PR #13`status-only analyst comment with doc links` (эталон формата аналитика).
- PR #14`external gitea_public_url for clickable doc links` (источник кликабельных ссылок).
- ADR не требуется: сквозной архитектурный сдвиг отсутствует, меняем только формирование текста коммента в существующем потоке.
## 9. Критерии успеха (high-level)
- Слава открывает любую задачу в Plane и в ленте видит однотипные карточки от каждого агента: «{role} — {описание} → ссылка [Verdict: …] [Длительность: …]».
- По любой ссылке открывается соответствующий документ в Gitea (HTTP 200, корректный путь).
- В каждом статус-комменте присутствует строка «Длительность: …» с человекочитаемым значением (`12s` / `4m 12s` / `1h 03m`).
- Никаких регрессий в существующих тестах `tests/`.

View File

@@ -1,174 +0,0 @@
# ТЗ: Единообразные коммент-артефакты в Plane от всех агентов
Work Item ID: **ORCH-016**
Стадия: analysis → architecture → development
Автор: analyst
Дата: 2026-06-05
Ревизия: 2 (по фидбэку стейкхолдера — добавлен §2.5 Duration; обновлены §1, §2.1, §6)
> Контракт: что именно меняем в коде / какие модули задействованы / какие проверки появятся.
> Архитектурные решения принимает архитектор; здесь — границы изменения.
---
## 1. Задействованные модули
| Модуль | Роль в изменении |
|--------|------------------|
| `src/usage.py` | **Главная точка изменения.** Здесь сейчас живут `usage_comment()`, `artifact_links()`, `AGENT_ARTIFACT`, `AGENT_DISPLAY`, `AGENT_ICON` — основа форматирования. Нужно расширить/добавить хелпер построения единого status-коммента + утилитку форматирования длительности (`fmt_duration(seconds: int) -> str`). |
| `src/stage_engine.py` | Эталонная функция аналитика `_build_analyst_ready_comment()`. По возможности — переиспользовать новый общий хелпер (или хотя бы выровнять формат: emoji + заголовок + описание + список ссылок). К аналитику также прикручиваем строку длительности (см. §2.5). |
| `src/agents/launcher.py` | `_post_usage_comments()` — точка, где постится коммент по завершении агента (architect/developer/reviewer/tester/deployer). Должен звать новый хелпер. `_duration_s` уже считается на строке `391` — пробросить его (или достать из `agent_runs.started_at`/`finished_at`) в хелпер. |
| `src/db.py` | **Только для чтения** в рантайме коммент-хелпера: `agent_runs.started_at`, `agent_runs.finished_at` (уже существуют). Никаких ALTER. |
| `src/plane_sync.py` | `add_comment()` — без изменений (используется как транспорт). |
| `src/qg/checks.py` | **Только для чтения**: модели парсинга frontmatter `verdict:` / `deploy_status:` / `staging_status:` — переиспользуем эту логику (вынести в отдельную утилитку, либо импортировать там, где она уже есть). |
| `src/config.py` | `settings.gitea_public_url`, `settings.gitea_owner`, `settings.gitea_url` — без изменений, переиспользуются. |
## 2. Контракт нового коммент-формата
### 2.1 Структура (одинакова для всех агентов)
```
{ICON} {RoleName} — {one-line human description of stage result}
[Verdict / Status: <VALUE>] # опционально, см. 2.3
Длительность: <human-format> # см. 2.5; опускается, только если значение неизвестно
<b>Документы:</b>
• <a href="…">{label}</a> # одна или несколько ссылок
```
Поля:
- `{ICON}` — берётся из `AGENT_ICON` (уже есть в `usage.py`).
- `{RoleName}` — из `AGENT_DISPLAY` (уже есть).
- `{description}` — фиксированная строка на роль, см. 2.2.
- Verdict / Status — см. 2.3, опускается если не извлекается.
- Длительность — см. 2.5, печатается всегда, когда значение есть; по умолчанию доступна (это нативная метрика `agent_runs`).
- Ссылки — см. 2.4.
### 2.2 Описания стадий (per-agent text)
| Агент | Описание (рус.) |
|-------|------------------|
| analyst | «Подготовил BRD / ТЗ / Acceptance Criteria. Для продвижения переведите задачу в статус Approved.» (как сейчас в `_build_analyst_ready_comment`) |
| architect | «Завершил архитектурную проработку. См. ADR ниже.» |
| developer | «Завершил разработку. См. PR / branch ниже.» |
| reviewer | «Завершил ревью изменений.» |
| tester | «Завершил прогон тестов.» |
| deployer | «Завершил деплой.» |
Точные формулировки финализирует architect; аналитик фиксирует **факт** наличия 1-предложного описания на каждую роль.
### 2.3 Verdict / Status строка
Печатается отдельной строкой над списком документов. Источник — frontmatter артефакта; парсить идемпотентно (если файл недоступен — строку пропустить):
| Агент | Поле | Где парсим | Возможные значения | Формат строки |
|-------|------|------------|---------------------|----------------|
| analyst | — | — | — | не печатается |
| architect | — | — | — | не печатается |
| developer | — | — | — | не печатается (CI-статус — отдельный гейт) |
| reviewer | `verdict:` | `docs/work-items/<wid>/12-review.md` (YAML-frontmatter) | `APPROVE` / `REQUEST_CHANGES` | `Verdict: APPROVE` |
| tester | `verdict:` (или эквивалентный фронт-кей) | `docs/work-items/<wid>/13-test-report.md` | `PASS` / `FAIL` | `Verdict: PASS` |
| deployer | `staging_status:` (для deploy-staging) / `deploy_status:` (для deploy) | `15-staging-log.md` / `14-deploy-log.md` | `SUCCESS` / `FAILED` | `Status: SUCCESS` |
Если значение в frontmatter отсутствует или не распознано → строка `Verdict / Status` НЕ выводится (вердикт-парсинг гейтов и сама логика гейтов не меняется).
### 2.4 Ссылки на артефакты
Базовый URL: `(settings.gitea_public_url or settings.gitea_url).rstrip('/')`.
Префикс: `/{owner}/{repo}/src/branch/{branch}/`.
| Агент | Артефакты (label → путь) |
|-------|----------------------------|
| analyst | BRD `01-brd.md`, ТЗ `02-trz.md`, AC `03-acceptance-criteria.md`, Test Plan `04-test-plan.yaml` *(уже есть)* |
| architect | ADR-папка `docs/work-items/<wid>/06-adr/` *(уже есть)* |
| developer | Branch `…/src/branch/<branch>`, PR `…/pulls/<num>` *(уже есть)* |
| reviewer | Review `docs/work-items/<wid>/12-review.md` *(уже есть)* |
| tester | Test report `docs/work-items/<wid>/13-test-report.md` *(уже есть)* |
| deployer | Deploy log `docs/work-items/<wid>/14-deploy-log.md`; staging-лог `15-staging-log.md` (если применимо к стадии) |
Несуществующий файл в worktree → ссылка опускается (как сейчас в `_build_analyst_ready_comment`).
### 2.5 Строка длительности работы агента
**Что печатаем:** одну строку вида `Длительность: {human}` (или `Duration: {human}` — финальную локализацию метки фиксирует архитектор; русский предпочтителен, остальные комменты уже на русском).
**Источник значения (приоритет сверху вниз):**
1. **Параметр функции**`_post_usage_comments()` в `src/agents/launcher.py:682` вызывается из контекста, где `_duration_s` уже посчитан на строке `391` (`int(time.time() - _start_ts)`). Простейший путь — пробросить `duration_s` явным аргументом в `usage_comment(...)` / новый `build_status_comment(...)`.
2. **Fallback из БД** — если параметр не передан (например, для аналитика, чей коммент строится в `_build_analyst_ready_comment` в `src/stage_engine.py:298`), читаем
```sql
SELECT
CAST((julianday(finished_at) - julianday(started_at)) * 86400 AS INTEGER)
FROM agent_runs
WHERE task_id = ? AND agent = ?
ORDER BY id DESC LIMIT 1
```
Это последний завершённый run этой роли по задаче.
3. **Если оба источника пусты / `None` / отрицательны** — строка `Длительность:` НЕ печатается (graceful, как и для вердикта).
**Форматирование (`fmt_duration(seconds: int) -> str` в `src/usage.py`):**
| Диапазон | Формат | Пример |
|----------|--------|--------|
| `0 ≤ s < 60` | `{s}s` | `12s`, `45s` |
| `60 ≤ s < 3600` | `{m}m {ss}s` | `4m 12s`, `1m 03s` |
| `s ≥ 3600` | `{h}h {mm}m` (секунды отбрасываем) | `1h 03m`, `2h 47m` |
Округление: целые секунды (input — `int`). При `s == 0` всё равно печатаем `0s` (видно, что метрика известна и стадия отработала почти мгновенно).
**Покрытие ролей:** строка длительности добавляется для **всех** агентов, включая аналитика. Для аналитика — строго через fallback из `agent_runs` (его коммент строится в `stage_engine.py`, не в `launcher.py`).
**Что НЕ делаем:**
- Не меняем схему `agent_runs` (поля `started_at` / `finished_at` уже есть, `_duration_s` уже считается).
- Не изобретаем новый отдельный коммент с длительностью — длительность встраивается в существующий status-коммент.
- Не считаем «время от первого вебхука до коммента» — берём чистое время процесса агента (тот же `_duration_s`, что попадает в `notify_agent_finished`), чтобы значение совпадало с тем, что уже видно в Telegram live tracker / логах.
### 2.6 Один коммент на агента за стадию
Текущий триггер — `_post_usage_comments()` вызывается **один раз** в успешном auto-advance пути после агента. Никаких новых триггеров не добавляем. Дубликаты исключены текущей логикой (одно завершение агента → один коммент).
### 2.7 Usage-метрики (токены / стоимость)
Текущий `usage_comment()` встраивает «8.5M in / 45.8k out · $7.29» в первый строкой. По требованиям Славы это «без раздувания», но не запрещено явно. Решение:
- **Сохранить** usage-метрику как **последнюю строку** коммента (мелким техническим хвостом, например `<sub>8.5M in / 45.8k out · $7.29 · Длительность: 4m 12s</sub>`), либо
- **Перенести** в `task_summary_comment` (только для финального deployer-summary).
Финальный выбор — за архитектором (см. вопрос Q-1 в `10-tech-risks.md`). Длительность из §2.5 — **отдельная** строка от usage-метрики и присутствует независимо от того, как решится вопрос про токены/стоимость.
### 2.8 Бот-авторство
`plane_add_comment(..., author=<role>)` — сохраняется. Все агенты комментируют под своим bot-токеном (`PLANE_BOT_TOKENS`). Изменения формата текста на это не влияют.
## 3. Изменения API
**Нет.** Внешние webhooks (`/webhook/plane`, `/webhook/gitea`), `/health`, `/status`, `/queue` — не меняются.
## 4. Изменения схемы БД
**Нет.** Используются существующие таблицы `tasks`, `agent_runs`, `jobs`.
## 5. Новые Quality Gate checks
**Нет.** Гейты не меняются. Парсинг `verdict:` / `deploy_status:` / `staging_status:` в коммент — отдельная утилитка, не QG.
## 6. Требования к коду
- Все новые функции — с docstring (зачем нужны, какие инварианты сохраняют).
- Парсинг frontmatter артефакта — graceful: исключение → строка вердикта опускается, лог в `logger.debug`.
- Чтение длительности — graceful: исключение или `None` → строка длительности опускается, лог в `logger.debug`. Отрицательные / нулевые значения: `0` печатается как `0s`, отрицательные опускаются.
- `fmt_duration(seconds: int) -> str` — чистая, без БД-зависимостей, легко тестируется юнитом.
- Никаких новых внешних зависимостей: использовать `pyyaml` (уже в проекте) или существующий парсер frontmatter из `src/qg/checks.py`.
- Поведение для проектов **без** артефактов (например, ENDURO-* до запуска агента) — graceful no-op: коммент с описанием и без ссылок (минимум — заголовок).
- HTML (как у аналитика) предпочтительнее markdown — Plane корректно рендерит `<ul><li><a>` и `<b>`.
## 7. Артефакты по pipeline
- `06-adr/` — **не требуется** (нет архитектурного сдвига; обсуждается локально архитектором, в случае спорного решения по 2.6 — заводим ADR `ADR-001-status-comment-format.md`).
- `07-infra-requirements.md` — **не требуется** (нет новой инфраструктуры).
- `08-data-requirements.md` — **не требуется** (БД не меняется).
- `12-review.md` / `13-test-report.md` / `14-deploy-log.md` — формируются на соответствующих стадиях по канону.
- `CHANGELOG.md` — обновить в том же PR (раздел `Unreleased`).
## 8. Документация
В том же PR обновить:
- `docs/architecture/README.md` — короткое упоминание единого формата комментов (можно в раздел «Plane Sync»).
- `docs/architecture/internals.md` — если там есть раздел про `usage.py`/комменты — обновить.
- `CLAUDE.md` — без изменений (правила не меняются).
## 9. Чего НЕ делать
- Не менять реестр `QG_CHECKS`.
- Не менять `STAGE_TRANSITIONS`.
- Не менять `add_comment` / `_headers_for` / `PLANE_BOT_TOKENS`.
- Не «комментировать» комменты других стадий задним числом.
- Не использовать `--no-verify` при коммитах.

View File

@@ -1,125 +0,0 @@
# Acceptance Criteria: Единообразные коммент-артефакты в Plane
Work Item ID: **ORCH-016**
Ревизия: 2 (по фидбэку стейкхолдера — все AC по агентам обновлены под строку длительности; добавлены AC-13 / AC-14)
Каждый AC сформулирован как чёткое условие PASS/FAIL. Проверяется автоматически (unit/integration) либо ручной верификацией в staging Plane (порт 8501).
---
## AC-1. Архитектор пишет единообразный коммент
- **Given** task завершила стадию `architecture` успешно, `06-adr/` содержит как минимум один ADR.
- **When** `_post_usage_comments(agent="architect", ...)` вызывается.
- **Then** в Plane появляется **ровно один** коммент со структурой:
- первая строка: `📐 Architect — Завершил архитектурную проработку. См. ADR ниже.`,
- строка `Длительность: <human>` (формат — см. AC-13), значение соответствует фактическому времени работы архитектора (±1с),
- блок «Документы:» с кликабельной ссылкой на `…/src/branch/<branch>/docs/work-items/<wid>/06-adr/`,
- **нет** строки `Verdict / Status`.
- **And** автор коммента — `architect` (`PLANE_BOT_TOKENS["architect"]`, fallback на shared token).
- **PASS** при выполнении всех пунктов; **FAIL** при отсутствии любого.
## AC-2. Разработчик пишет единообразный коммент
- **Given** task завершила стадию `development`, есть open PR.
- **When** `_post_usage_comments(agent="developer", ...)` вызывается.
- **Then** коммент в Plane:
- `💻 Developer — Завершил разработку. См. PR / branch ниже.`,
- строка `Длительность: <human>`,
- ссылки: `Branch <branch>``…/src/branch/<branch>`, `PR #<num>``…/pulls/<num>`,
- **нет** строки `Verdict`.
## AC-3. Ревьюер пишет коммент с вердиктом
- **Given** `12-review.md` содержит frontmatter `verdict: APPROVE` (или `REQUEST_CHANGES`).
- **When** `_post_usage_comments(agent="reviewer", ...)` вызывается.
- **Then** коммент:
- `🔎 Reviewer — Завершил ревью изменений.`,
- строка `Verdict: APPROVE` (или `REQUEST_CHANGES`) — содержимое соответствует frontmatter,
- строка `Длительность: <human>`,
- ссылка `Review``…/12-review.md`.
- **And** если frontmatter не содержит `verdict:` или файл недоступен — строка `Verdict:` опускается, остальное (в т.ч. длительность) публикуется.
## AC-4. Тестер пишет коммент с вердиктом
- **Given** `13-test-report.md` содержит frontmatter `verdict: PASS` (или `FAIL`).
- **When** `_post_usage_comments(agent="tester", ...)` вызывается.
- **Then** коммент:
- `🧪 Tester — Завершил прогон тестов.`,
- строка `Verdict: PASS` (либо `FAIL`),
- строка `Длительность: <human>`,
- ссылка `Test report``…/13-test-report.md`.
## AC-5. Деплоер пишет коммент со статусом
- **Given** task прошла стадию `deploy` (или `deploy-staging`), артефакт-лог существует с frontmatter `deploy_status: SUCCESS` (или `staging_status: SUCCESS`).
- **When** `_post_usage_comments(agent="deployer", ...)` вызывается.
- **Then** коммент:
- `🚀 Deployer — Завершил деплой.`,
- строка `Status: SUCCESS` (или `FAILED`),
- строка `Длительность: <human>`,
- ссылка `Deploy log``…/14-deploy-log.md` (и/или `Staging log``…/15-staging-log.md` для staging-стадии).
## AC-6. Аналитик не регрессирует
- **Given** существующий поток PR #12/#13 (status-only verdict).
- **When** аналитик завершает стадию `analysis` с готовыми `01..04`.
- **Then** в Plane:
- issue переведён в `In Review` (не меняется),
- коммент содержит **то же** человеческое описание (Approved/Rejected инструкции) и список ссылок `BRD / ТЗ / AC / Test Plan` — формат либо идентичен текущему, либо построен через тот же общий хелпер, что и остальные агенты, без потери смысла,
- дополнительно к существующему содержимому в комменте присутствует строка `Длительность: <human>` — значение поднимается из `agent_runs` (последний завершённый run агента `analyst` для этой задачи).
## AC-7. Один коммент на агента за стадию
- **Given** агент успешно отработал стадию.
- **When** наблюдаем ленту Plane.
- **Then** для **каждого** агента (`architect`, `developer`, `reviewer`, `tester`, `deployer`) на стадию приходится **ровно один** status-коммент с артефактами. Дополнительные сервисные комменты (`notify_stage_change`, `notify_qg_failure`, `notify_done`) сохраняются — они не считаются status-комментом.
## AC-8. Graceful fallback при отсутствии артефакта
- **Given** артефакт (например, `12-review.md`) ОТСУТСТВУЕТ в worktree на момент коммента (нестандартный сценарий).
- **When** `_post_usage_comments(agent="reviewer", ...)` вызывается.
- **Then** коммент всё равно публикуется: заголовок + описание, без ссылки на отсутствующий артефакт и без строки `Verdict:`. Исключения не пробрасываются.
## AC-9. Кликабельность через gitea_public_url
- **Given** в `.env` задан `GITEA_PUBLIC_URL=https://git.mva154.duckdns.org`, отличный от `GITEA_URL`.
- **When** любой агент пишет status-коммент.
- **Then** href всех артефакт-ссылок начинается с `https://git.mva154.duckdns.org/` (а не с внутреннего `gitea_url`).
- **And** при отсутствии `gitea_public_url` (пустая строка) — fallback на `gitea_url` (обратная совместимость).
## AC-10. Существующие тесты зелёные
- **Given** новый код влит в feature-ветку.
- **When** запускается `pytest tests/ -q`.
- **Then** все ранее существовавшие тесты проходят (нет регрессий status-only verdict, дедупа, `set_issue_done`).
## AC-11. Quality Gates не меняются
- **Given** изменения формата комментов.
- **When** инспектируется `src/qg/checks.py` и `src/stages.py`.
- **Then** реестр `QG_CHECKS` и `STAGE_TRANSITIONS` остаются идентичными версии до PR (diff в этих файлах = ∅).
## AC-12. Документация обновлена
- **Given** реализация добавлена в feature-ветку.
- **When** reviewer проверяет PR.
- **Then** в diff присутствуют обновления:
- `CHANGELOG.md` (раздел Unreleased, описание изменения — включая «строку длительности агента в комментах»),
- `docs/architecture/README.md` или `docs/architecture/internals.md` (упоминание единого формата status-комментов и строки длительности).
- **And** при отсутствии обновлений документации reviewer ставит `verdict: REQUEST_CHANGES` (правило проекта).
## AC-13. Формат строки длительности
- **Given** утилитка `fmt_duration(seconds: int) -> str` в `src/usage.py`.
- **When** ей передаются граничные значения.
- **Then** возвращаемая строка соответствует таблице:
- `0``"0s"`
- `12``"12s"`
- `59``"59s"`
- `60``"1m 00s"`
- `252``"4m 12s"`
- `3599``"59m 59s"`
- `3600``"1h 00m"`
- `3780``"1h 03m"`
- `10020``"2h 47m"`
- **And** ввод `None` или отрицательное значение → функция возвращает пустую строку (или `None`), а вызывающая сторона строку `Длительность:` не печатает.
- **PASS** при полном совпадении со всеми примерами таблицы.
## AC-14. Длительность — graceful fallback
- **Given** агент завершился, но `_duration_s` не пробрасывается явным параметром в коммент-хелпер (например, для аналитика).
- **When** строится status-коммент.
- **Then** хелпер запрашивает БД: последний `agent_runs` для `(task_id, agent)` с непустым `finished_at`, считает `int((julianday(finished_at) - julianday(started_at)) * 86400)` и подставляет в `fmt_duration`.
- **And** при отсутствии подходящей строки `agent_runs` (или `finished_at IS NULL`, или результат < 0) — строка `Длительность:` опускается; остальные части коммента (заголовок, описание, вердикт, ссылки) публикуются без изменений.
- **And** ошибка чтения БД не пробрасывает исключение наружу — логируется в `logger.debug` и трактуется как «значение неизвестно».
---
**Финальный PASS задачи:** все AC-1…AC-14 = PASS.

View File

@@ -1,154 +0,0 @@
work_item: ORCH-016
title: "Единообразные коммент-артефакты в Plane от всех агентов"
revision: 2 # +TC-21..TC-25 по длительности (фидбэк стейкхолдера)
tests:
- id: TC-01
type: unit
description: "build_status_comment(architect, duration_s=312, ...) формирует HTML c заголовком '📐 Architect — …', описанием стадии, строкой 'Длительность: 5m 12s' и ссылкой на 06-adr/. Строки Verdict нет."
module: tests/test_status_comment_format.py
expected: PASS
- id: TC-02
type: unit
description: "build_status_comment(developer, branch=..., pr_number=42, duration_s=...) включает ссылки на branch и на PR #42 через gitea_public_url + строку 'Длительность: ...'. Строки Verdict нет."
module: tests/test_status_comment_format.py
expected: PASS
- id: TC-03
type: unit
description: "build_status_comment(reviewer, duration_s=..., ...) при verdict=APPROVE в 12-review.md frontmatter выводит строку 'Verdict: APPROVE', строку 'Длительность: ...' и ссылку на 12-review.md."
module: tests/test_status_comment_format.py
expected: PASS
- id: TC-04
type: unit
description: "build_status_comment(reviewer, ...) при verdict=REQUEST_CHANGES выводит 'Verdict: REQUEST_CHANGES'. Строка длительности сохраняется."
module: tests/test_status_comment_format.py
expected: PASS
- id: TC-05
type: unit
description: "build_status_comment(reviewer, ...) при отсутствии файла 12-review.md публикует коммент без строки Verdict и без ссылки Review (graceful), при этом строка 'Длительность: ...' печатается, если duration_s передан."
module: tests/test_status_comment_format.py
expected: PASS
- id: TC-06
type: unit
description: "build_status_comment(tester, ...) при verdict=PASS в 13-test-report.md выводит 'Verdict: PASS', строку 'Длительность: ...' и ссылку на 13-test-report.md."
module: tests/test_status_comment_format.py
expected: PASS
- id: TC-07
type: unit
description: "build_status_comment(tester, ...) при verdict=FAIL выводит 'Verdict: FAIL'. Строка длительности сохраняется."
module: tests/test_status_comment_format.py
expected: PASS
- id: TC-08
type: unit
description: "build_status_comment(deployer, ...) при deploy_status=SUCCESS в 14-deploy-log.md выводит 'Status: SUCCESS', строку 'Длительность: ...' и ссылку на 14-deploy-log.md."
module: tests/test_status_comment_format.py
expected: PASS
- id: TC-09
type: unit
description: "build_status_comment(deployer, stage='deploy-staging') читает staging_status: из 15-staging-log.md и выводит соответствующую строку Status + строку длительности."
module: tests/test_status_comment_format.py
expected: PASS
- id: TC-10
type: unit
description: "URL ссылок строится через settings.gitea_public_url когда он задан; иначе — через settings.gitea_url (fallback)."
module: tests/test_status_comment_format.py
expected: PASS
- id: TC-11
type: unit
description: "Аналитик: _build_analyst_ready_comment (или его замена общим хелпером) сохраняет существующий контракт — текст про Approved/Rejected статус + список существующих BRD/ТЗ/AC/Test Plan ссылок. Дополнительно: при наличии завершённой строки agent_runs(analyst) для задачи коммент содержит строку 'Длительность: ...'."
module: tests/test_analyst_comment_regression.py
expected: PASS
- id: TC-12
type: unit
description: "Парсер frontmatter (verdict / deploy_status / staging_status) возвращает None при отсутствии файла, пустом файле или некорректном YAML — без проброса исключения."
module: tests/test_status_comment_format.py
expected: PASS
- id: TC-13
type: integration
description: "_post_usage_comments(agent='reviewer', ...) вызывает plane_sync.add_comment ровно один раз; передаваемый текст содержит '🔎 Reviewer', 'Verdict:', 'Длительность:' и href на 12-review.md."
module: tests/test_post_usage_comments_integration.py
expected: PASS
- id: TC-14
type: integration
description: "_post_usage_comments(agent='tester', ...) вызывает add_comment ровно один раз с автором 'tester' и корректным текстом, включая строку 'Длительность: ...'."
module: tests/test_post_usage_comments_integration.py
expected: PASS
- id: TC-15
type: integration
description: "_post_usage_comments(agent='deployer', ...) для стадии deploy постит коммент со ссылкой на 14-deploy-log.md, строкой 'Длительность: ...' И task_summary_comment (если оно сохраняется) — поведение не регрессирует."
module: tests/test_post_usage_comments_integration.py
expected: PASS
- id: TC-16
type: integration
description: "Регрессия status-only verdict model: при завершении analyst issue переводится в In Review, постится один коммент аналитика с инструкцией про статус Approved/Rejected, никакой автомат-advance не происходит."
module: tests/test_analyst_status_only_regression.py
expected: PASS
- id: TC-17
type: integration
description: "Регрессия дедупликации: повторный вебхук Plane с тем же event_id не приводит ко второму status-комменту от агента."
module: tests/test_status_comment_dedup_regression.py
expected: PASS
- id: TC-18
type: integration
description: "Регрессия set_issue_done / notify_done: финальный путь deploy→done по-прежнему переводит issue в Done и постит '✅ Task completed!' (отдельным комментом от status-коммента деплоера)."
module: tests/test_notify_done_regression.py
expected: PASS
- id: TC-19
type: integration
description: "Per-agent bot-авторство: status-комменты архитектора/разработчика/ревьюера/тестера/деплоера POST-ятся под соответствующим X-API-Key (PLANE_BOT_TOKENS[role]); fallback на PLANE_HEADERS при отсутствии бот-токена."
module: tests/test_status_comment_authorship.py
expected: PASS
- id: TC-20
type: unit
description: "Quality Gates не изменены: реестр QG_CHECKS и STAGE_TRANSITIONS идентичны контрольному снапшоту (smoke-тест против случайных правок)."
module: tests/test_qg_registry_snapshot.py
expected: PASS
- id: TC-21
type: unit
description: "fmt_duration(seconds) — табличная проверка форматирования: 0→'0s', 12→'12s', 59→'59s', 60→'1m 00s', 252→'4m 12s', 3599→'59m 59s', 3600→'1h 00m', 3780→'1h 03m', 10020→'2h 47m'."
module: tests/test_fmt_duration.py
expected: PASS
- id: TC-22
type: unit
description: "fmt_duration(None) и fmt_duration(-1) возвращают пустую строку (или None); вызывающая сторона при этом строку 'Длительность:' НЕ печатает."
module: tests/test_fmt_duration.py
expected: PASS
- id: TC-23
type: unit
description: "build_status_comment(architect, duration_s=None) и build_status_comment(architect) — коммент НЕ содержит строки 'Длительность:'; остальные строки (заголовок/описание/ссылки) на месте."
module: tests/test_status_comment_format.py
expected: PASS
- id: TC-24
type: integration
description: "Fallback по БД: при отсутствии явного duration_s билдер коммента читает agent_runs.started_at/finished_at для последней завершённой строки (task_id, agent) и подставляет fmt_duration результата. Проверка через тестовую SQLite с заранее проставленными timestamp'ами."
module: tests/test_status_comment_duration_db_fallback.py
expected: PASS
- id: TC-25
type: integration
description: "Регрессия: исключение при чтении agent_runs (например, БД залочена) → строка 'Длительность:' опускается, остальное публикуется; logger.debug содержит запись о неудачном чтении длительности."
module: tests/test_status_comment_duration_db_fallback.py
expected: PASS

View File

@@ -1,203 +0,0 @@
# ADR-001: Единый формат status-коммента агентов в Plane
- **Work Item:** ORCH-016
- **Стадия:** architecture
- **Статус:** Accepted
- **Дата:** 2026-06-05
- **Автор:** architect
## Контекст
ТЗ ORCH-016 требует привести коммент-формат всех агентов (architect/developer/reviewer/tester/deployer + сохранение совместимости с analyst) к единому виду по эталону `src/stage_engine.py::_build_analyst_ready_comment` и дополнительно встроить **строку длительности работы агента**.
ТЗ оставил архитектору пять открытых вопросов (см. §2.2, §2.5, §2.7, §6):
1. Где живёт общий хелпер построения коммента (один файл vs. два).
2. Как ведём себя с usage-метрикой (tokens / $cost) в новом формате (Q-1 из ТЗ §2.7).
3. Локализация метки длительности — «Длительность:» vs «Duration:».
4. Парсинг frontmatter артефакта (verdict / deploy_status / staging_status) — переиспользовать `src/qg/checks.py` или дублировать.
5. Контракт хелпера БД-фоллбэка длительности и его форма.
Дополнительно: текущий `usage_comment(...)` — публичная (внутри проекта) функция, вызывается из `src/agents/launcher.py::_post_usage_comments`. Менять формат «на месте» без явного решения о судьбе старой сигнатуры рискованно.
## Решение
### 1. Архитектура хелперов
Вводим **ровно один публичный хелпер** в `src/usage.py`:
```python
def build_status_comment(
agent: str, # "analyst" | "architect" | ... | "deployer"
*,
repo: str | None = None,
branch: str | None = None,
work_item_id: str | None = None,
pr_number: int | None = None,
stage: str | None = None, # "deploy" vs "deploy-staging" (для deployer)
usage: dict | None = None, # tokens/cost (опционально)
duration_s: int | None = None, # если известно — иначе fallback по БД
task_id: int | None = None, # требуется ТОЛЬКО для DB-фоллбэка длительности
worktree_root: str | None = None, # для чтения артефактов; None → опускаем verdict
) -> str:
```
Что делает:
- Собирает заголовок `{ICON} {RoleName} — {описание}` (описание per-agent — см. §2 ниже).
- Опционально дописывает строку `Verdict: …` / `Status: …` (только для reviewer/tester/deployer и только если frontmatter артефакта присутствует и распознан).
- Всегда (если известна) дописывает строку `Длительность: …` через `fmt_duration(...)`.
- Дописывает блок `<b>Документы:</b><ul><li><a …>…</a></li>…</ul>`.
- Опционально дописывает технический хвост `<sub>{tokens}/{cost}</sub>` — см. §3.
`_build_analyst_ready_comment(...)` в `src/stage_engine.py` переписывается как **тонкая обёртка** над `build_status_comment(agent="analyst", ...)`. Аналитик-специфичный текст (инструкция «переведите в Approved/Rejected» + полный список 01-brd / 02-trz / 03-acceptance-criteria / 04-test-plan) добавляется ВНУТРИ `build_status_comment` через ветку `agent == "analyst"` — это единственное место, где per-agent текст шире одной строки. Альтернатива (передавать кастомный текст параметром) добавляет API-площадь без пользы.
**Старый `usage_comment(...)` удаляется**; единственный его внешний вызов — `src/agents/launcher.py::_post_usage_comments` — переписывается на `build_status_comment(...)`. Это упрощает дальнейшее сопровождение (один формат → одна функция); риск минимален, потому что `usage_comment` — внутренний API.
### 2. Per-agent описания (финализация ТЗ §2.2)
| Агент | Описание (HTML, без точки в конце) |
|-------|------------------------------------|
| analyst | «Подготовил BRD / ТЗ / Acceptance Criteria. Для продвижения переведите задачу в статус Approved» (плюс существующая инструкция про Approved/Rejected уходит как продолжение) |
| architect | «Завершил архитектурную проработку. См. ADR ниже» |
| developer | «Завершил разработку. См. PR / branch ниже» |
| reviewer | «Завершил ревью изменений» |
| tester | «Завершил прогон тестов» |
| deployer (deploy) | «Завершил прод-деплой» |
| deployer (deploy-staging) | «Завершил staging-деплой» |
### 3. Решение по Q-1 (usage-метрика)
**Сохраняем** usage-метрику как **техническую `<sub>`-строку в конце** коммента, объединённую с длительностью НЕ нужно — длительность остаётся ОТДЕЛЬНОЙ строкой нормального веса (требование ТЗ §2.5).
Конкретно:
```html
<sub>8.5M in (8.4M cached) / 45.8k out · $7.29</sub>
```
Почему НЕ удаляем:
- Тех-метрика полезна для оценки стоимости задачи на пост-мортеме (особенно для ORCH-задач, где orchestrator расходует свой же бюджет).
- `task_summary_comment` (Deployer end-of-task) суммирует по задаче, но не покрывает per-agent breakdown в момент завершения каждой стадии — для трассировки «кто сколько потратил» полезно видеть сразу.
Почему `<sub>`, а не обычная строка:
- Стейкхолдер (Слава) явно просил «без раздувания»; визуально приглушённый хвост не конкурирует за внимание с описанием/вердиктом/длительностью/ссылками.
- Plane корректно рендерит `<sub>` (проверено ранее на PR #13).
При `usage = None` или нулевых значениях — хвост опускается полностью.
### 4. Решение по Q-2 (локализация метки длительности)
Используем русский: **`Длительность: 4m 12s`**.
Обоснование: все человеческие тексты комментов уже на русском (заголовок «Документы:», описания стадий). Метка `4m 12s` сама по себе универсальна и понятна без перевода (стандарт CLI-инструментов: `time`, `gh`, `kubectl`).
### 5. Решение по Q-4 (парсинг frontmatter)
Создаём НОВЫЙ маленький утилитный модуль **`src/frontmatter.py`** с единственной функцией:
```python
def read_frontmatter_value(path: str, key: str) -> str | None:
"""Read a single key from leading YAML frontmatter. Never raises.
Returns None if file missing, frontmatter absent/malformed, or key not set.
"""
```
Реализация — yaml.safe_load на блоке между двумя `---` строками; всё ловится одним `try/except``logger.debug``None`.
Этот модуль используют:
- `src/usage.py::build_status_comment` — для извлечения `verdict:` / `deploy_status:` / `staging_status:`.
- `src/qg/checks.py`НЕ обязательно мигрировать в этом PR (out-of-scope ORCH-016); миграция может пройти отдельной задачей-рефакторингом. **В этом PR `qg/checks.py` НЕ трогаем** — снижает blast radius и риск регрессии гейтов.
Дублирование (~10 строк YAML-парсера в `qg/checks.py` остаётся) сознательно принято: scope discipline > DRY на одном переиспользовании.
### 6. Решение по Q-5 (DB-фоллбэк длительности)
Хелпер в `src/usage.py`:
```python
def get_agent_duration(task_id: int, agent: str) -> int | None:
"""Return last finished agent_runs duration (seconds) for (task, agent).
Never raises. None on missing row / NULL finished_at / negative / error.
"""
```
SQL — ровно как в ТЗ §2.5 (фоллбэк):
```sql
SELECT CAST((julianday(finished_at) - julianday(started_at)) * 86400 AS INTEGER)
FROM agent_runs
WHERE task_id=? AND agent=?
AND finished_at IS NOT NULL
ORDER BY id DESC LIMIT 1
```
Чтение через `get_db()` (стандартный путь модуля), обёрнутое в `try/except Exception``logger.debug(...)``None`. Соединение всегда закрывается в `finally`.
`build_status_comment` вызывает `get_agent_duration(...)` ТОЛЬКО когда:
- `duration_s is None`, И
- `task_id is not None` (вызывающая сторона согласилась оплатить лишний SELECT).
Если оба источника пусты → строка «Длительность:» опускается (AC-14).
### 7. Решение по HTML vs Markdown (ТЗ §6)
Целевой рендер — **HTML**, как у эталона аналитика. Конкретно:
- Заголовок и описание — plain text + emoji.
- Verdict / Длительность — отдельные строки, разделяются `<br>` (или `\n` если Plane корректно интерпретирует переводы строк; экспериментально подтвердить на staging — см. R-2 в `10-tech-risks.md`).
- Блок документов — `<b>Документы:</b><ul><li><a href="…">label</a></li></ul>`.
- Технический хвост — `<sub>…</sub>` отдельной строкой через `<br>`.
`artifact_links(...)` (сейчас возвращает markdown-строки `[label](url)`) — **переписывается на HTML-якоря** `<a href="...">label</a>`. Эмодзи-префиксы (📂/🔗/📐/📄) сохраняются. Возвращаемый тип меняется: `list[str]` остаётся, но содержимое — HTML-фрагменты (документировано в docstring).
Это breaking-change для внутреннего API `artifact_links`, но единственный внешний вызов был из `usage_comment`, который тоже удаляется. Других вызовов в `tests/`/`scripts/` нет (developer проверит grep'ом в development-стадии).
### 8. Контракт `fmt_duration` (полностью по AC-13)
```python
def fmt_duration(seconds: int | None) -> str:
"""0..59 → '{s}s'; 60..3599 → '{m}m {ss:02d}s'; >=3600 → '{h}h {mm:02d}m'.
None / negative → '' (caller should drop the line)."""
```
Чистая функция, без I/O, easily unit-testable. Размещение: `src/usage.py` (рядом с `fmt_tokens` / `fmt_cost`).
## Альтернативы
1. **Два отдельных хелпера** (`build_analyst_status_comment` + `build_agent_status_comment`).
Отклонено: ТЗ явно просит «единый эталонный формат»; дублирование шаблона расходится со временем.
2. **Оставить `usage_comment` как deprecated-обёртку.**
Отклонено: один внутренний вызов, deprecation добавляет когнитивный шум без выигрыша.
3. **Перенести usage-метрику в `task_summary_comment` (вариант B из ТЗ §2.7).**
Отклонено: теряем per-stage видимость затрат; финальный summary не отвечает на вопрос «сколько съел конкретно reviewer».
4. **Markdown вместо HTML.**
Отклонено: эталон аналитика (PR #13) уже HTML; смена ломает визуальный паритет.
5. **Английская метка «Duration:».**
Отклонено: ассиметрия с остальными русскими подписями в комменте.
6. **Рефакторить `qg/checks.py` на `src/frontmatter.py` в этом же PR.**
Отклонено: расширяет blast radius на гейты; делаем отдельной задачей.
## Последствия
### Положительные
- Единая точка изменения формата комментов на будущее — `build_status_comment`.
- Удаление дубликата `usage_comment` уменьшает API-площадь модуля.
- `src/frontmatter.py` подготавливает почву для будущего рефактора `qg/checks.py` (DRY-победа в один заход следующей задачей).
- HTML-рендеринг даёт стейкхолдеру кликабельные ссылки и приглушённый тех-хвост.
### Отрицательные / ограничения
- Дублирование YAML-парсинга на ~10 строк (qg/checks.py остаётся со своим).
- Дополнительный SELECT к `agent_runs` на каждый коммент аналитика (1 запрос, по индексу `task_id`, ничтожно).
- HTML-разметка ломается визуально, если Plane изменит политику санитизации `<sub>` или `<ul>` (риск R-2).
### Self-hosting
- Хелперы — чистый код, без рестарта прод-контейнера. Изменения дойдут до прода через стандартный staging-гейт (`deploy-staging``deploy`).
- Если коммент сломается, ленту Plane задачи ORCH-016 первой и заметим — feedback loop коротко.
## Связи
- ТЗ §1, §2, §6 (`docs/work-items/ORCH-016/02-trz.md`)
- AC-1..AC-14 (`docs/work-items/ORCH-016/03-acceptance-criteria.md`)
- PR #13 (эталон аналитика — `_build_analyst_ready_comment`)
- PR #14 (`gitea_public_url` для кликабельных ссылок)
- `src/usage.py`, `src/stage_engine.py`, `src/agents/launcher.py`, `src/db.py`, `src/qg/checks.py`

View File

@@ -1,112 +0,0 @@
# Технические риски — ORCH-016
Work Item: **ORCH-016**
Стадия: architecture
Автор: architect
Дата: 2026-06-05
> Риски ранжированы по приоритету (P0 = блокер, P1 = серьёзный, P2 = умеренный, P3 = информационный).
> Каждый риск содержит митигацию и/или способ детекции на тестах.
---
## R-1 (P1) — Self-hosting: сломанный коммент => слепая зона по ORCH-задаче
**Описание.** Изменение касается генерации комментов; орк дорабатывает сам себя. Если новый `build_status_comment` падает / отдаёт пустую строку / отдаёт битый HTML, стейкхолдер (Слава) потеряет видимость прогресса именно по той задаче, которая сломала комменты — и не сможет диагностировать без `docker logs`.
**Митигация.**
- Внешний `try/except Exception` вокруг сборки HTML: при любом исключении возвращаем простой fallback-текст вида `f"{icon} {role} готов"` + `logger.exception(...)`. Лучше «уродливый» коммент, чем тишина.
- Юнит-тесты `tests/test_status_comment_format.py` (TC-01..TC-12, TC-23) фиксируют золотой HTML — регрессия ловится на CI до прод-деплоя.
- Обязательный staging-гейт (`check_staging_status` для orchestrator) — финальный предохранитель: задача с ORCH-меткой не дойдёт до прод-контейнера, пока staging-инстанс (8501) не подтвердит, что комменты собираются.
## R-2 (P1) — Plane HTML sanitization: `<sub>` / `<br>` / `<ul>` могут не рендериться
**Описание.** Plane (self-hosted) санитизирует входящий HTML. Эталон аналитика подтверждает рендер `<ul>` / `<li>` / `<a>` / `<b>`; **рендер `<sub>` и `<br>` НЕ подтверждён** на текущей версии Plane.
**Митигация.**
- На staging (8501) опубликовать тестовый коммент `build_status_comment(...)` руками (через `python -m` скрипт или curl на dev-задачу) и визуально проверить рендер тех-хвоста и переводов строк ПЕРЕД мержем PR.
- Если `<sub>` не рендерится — fallback: оставить usage-метрику обычной строкой с `· ` разделителем (без `<sub>`).
- Если `<br>` не рендерится — переходим на `\n` (Plane сам интерпретирует) либо упаковываем строки в `<p>...</p>`.
- Развилка фиксируется в `12-review.md` reviewer'ом по факту проверки.
**Детекция.** Ручной чек-лист в staging-логе (`15-staging-log.md`) с приложенным скриншотом коммента.
## R-3 (P2) — SQLite contention при DB-фоллбэке длительности
**Описание.** `get_agent_duration(task_id, agent)` делает SELECT по `agent_runs` в момент сборки коммента. SQLite-БД одновременно используется очередью (`jobs`), воркером, вебхуками и Telegram-трекером; пиковая нагрузка → коротко блокирующиеся читатели.
**Митигация.**
- Запрос идёт по индексу `(task_id, agent)` (если его нет — добавление индекса не входит в scope ORCH-016, но запрос всё равно быстрый: типичный `agent_runs` ≤ 50 строк на задачу).
- `try/except Exception` оборачивает SELECT → `logger.debug(...)``None`. При залоченной БД строка «Длительность:» просто опускается (AC-14).
- Запрос делаем ТОЛЬКО когда `duration_s` не передан явно (т.е. только для аналитика).
**Детекция.** TC-25 — integration-тест на исключение в чтении `agent_runs`.
## R-4 (P3) — Расхождение значений длительности (param vs DB)
**Описание.** `_duration_s` в `src/agents/launcher.py:391` считается как `int(time.time() - _start_ts)`. DB-фоллбэк считает `(julianday(finished_at) - julianday(started_at)) * 86400`. Возможно расхождение в 1 секунду (округление) или больше (если `finished_at` пишется не сразу).
**Митигация.** AC-13 допускает погрешность ±1с. Для аналитика, где используем только DB-фоллбэк, отклонений между двумя источниками не наблюдается (источник один).
**Не митигируется специально** — последствия нулевые (декоративная строка).
## R-5 (P2) — Скрытые callers `usage_comment` / `artifact_links`
**Описание.** ADR-001 предписывает удалить `usage_comment` и переписать `artifact_links` на HTML. В рамках только grep по `src/` я нашёл единственного клиента — `_post_usage_comments` в `src/agents/launcher.py`. Однако функция могла использоваться скриптами (`scripts/`), тестами (`tests/`), миграционными утилитами или внешними интеграциями.
**Митигация.** Developer на стадии development обязан выполнить полный grep:
```bash
grep -rn "usage_comment\|artifact_links" . --include="*.py"
```
И переписать все вызовы. Если найдётся внешний потребитель — оставить `usage_comment` как deprecated-обёртку и зафиксировать в `12-review.md`.
**Детекция.** TC-10 (полный pytest зелёный), TC-17 (дедуп-регрессия), reviewer-чек.
## R-6 (P2) — Регрессия status-only verdict model аналитика (PR #12/#13)
**Описание.** Аналитик переходит в `In Review` И не должен auto-advance'иться — статус ждёт Approved/Rejected от стейкхолдера. Если переписывание `_build_analyst_ready_comment` на обёртку случайно вернёт `auto_advance=True` или поменяет content так, что человек не поймёт инструкцию — порвётся существующий контракт.
**Митигация.**
- TC-11 + TC-16: регрессионные тесты на формат коммента и status-only поведение.
- ADR-001 §1 явно фиксирует: контракт аналитика сохраняется; обёртка строит ИДЕНТИЧНЫЙ существующему текст + добавляет только строку длительности.
## R-7 (P3) — Локализация и кодировка emoji в HTML
**Описание.** В `src/usage.py` emoji-ы записаны `\Uxxxxxxxx`-escape'ами. При сборке HTML это безопасно (Python декодирует до utf-8), но при возможном последующем base64/quoted-printable транспорте могла бы возникнуть проблема. Plane API принимает utf-8 → риск минимален.
**Митигация.** Не требуется. Существующий путь (PR #13, аналитик) уже посылает emoji через тот же `add_comment` без проблем.
## R-8 (P3) — Дублирование YAML-парсинга frontmatter
**Описание.** ADR-001 §5 принимает дублирование (~10 строк) в `src/frontmatter.py` и оставляет `src/qg/checks.py` со своим парсером. При расхождении правил (например, мы научим `read_frontmatter_value` поддерживать `---\nkey: value\n---` без trailing newline, а `qg/checks.py` останется строгим) теоретически возможны несогласованные интерпретации.
**Митигация.** Принято в scope discipline; следующая задача-рефактор объединит. До тех пор — `read_frontmatter_value` обязан быть строго совместимым (по тестам) с поведением `qg/checks.py` на канонических случаях (BR-frontmatter с trailing newline после `---`).
## R-9 (P0) — НЕ перезапускать прод-контейнер `orchestrator`
**Описание.** Self-hosting: прод-контейнер (8500) обслуживает ВСЕ проекты (orchestrator + enduro-trails) из общей БД. Внеплановый рестарт ради «быстро посмотреть формат коммента» = простой конвейера всех проектов.
**Митигация.**
- Все эксперименты — на staging (8501) через `docker compose --profile staging up -d orchestrator-staging`.
- Прод-деплой только через стандартный путь `deploy-staging → deploy` (под надзором `check_staging_status`).
- ЗАПРЕЩЕНО при ручном тестировании коммента дёргать `docker compose restart orchestrator`.
---
## Открытые вопросы (Q&A — все закрыты ADR-001)
| Q | Вопрос | Решение | Где зафиксировано |
|---|--------|---------|-------------------|
| Q-1 | Куда девать usage-метрику (tokens/cost)? | Сохранить как `<sub>…</sub>` хвостом в том же комменте. | ADR-001 §3 |
| Q-2 | «Длительность:» или «Duration:»? | «Длительность:» (русский, соответствует остальным меткам). | ADR-001 §4 |
| Q-3 | Один общий хелпер или раздельные для analyst/прочих? | Один: `build_status_comment(...)`; analyst — ветка внутри. | ADR-001 §1 |
| Q-4 | Парсер frontmatter — переиспользовать `qg/checks.py` или новый? | Новый `src/frontmatter.py`; `qg/checks.py` НЕ трогаем в этом PR. | ADR-001 §5 |
| Q-5 | Контракт DB-фоллбэка длительности. | `get_agent_duration(task_id, agent) -> int | None`, см. SQL в ADR-001 §6. | ADR-001 §6 |
| Q-6 | HTML vs Markdown. | HTML (как у эталона); `artifact_links` переписывается на `<a>`. | ADR-001 §7 |
| Q-7 | Судьба старого `usage_comment(...)`. | Удалить, перевести единственного клиента (`_post_usage_comments`) на `build_status_comment`. | ADR-001 §1 |
Если developer на стадии development обнаружит, что R-5 материализуется (есть скрытый клиент `usage_comment`) — допустимо оставить `usage_comment` как 1-строчную deprecated-обёртку (`return build_status_comment(...)`) и зафиксировать факт в `12-review.md` без возврата в architecture.
---
*Risk register для ORCH-016. Обновляется reviewer'ом, если в ходе ревью всплывут новые риски — текущий список фиксирует видимое на момент завершения стадии architecture.*

View File

@@ -1,120 +0,0 @@
---
type: review
work_item_id: ORCH-016
verdict: APPROVED
version: 1
---
# Review ORCH-016 — Единый status-коммент агентов в Plane
## Summary
PR реализует ТЗ ORCH-016 и ADR-001 полностью: вводится единый хелпер
`src/usage.build_status_comment(...)` для всех ролей (analyst…deployer),
строка `Длительность: …` с явным `duration_s` от launcher и DB-фоллбэком для
аналитика, defensive YAML-парсер `src/frontmatter.read_frontmatter_value`,
HTML-формат с эмодзи / Verdict / Документы / `<sub>` тех-хвостом. Аналитик
переведён на ту же ветку без регрессии (`tests/test_analyst_comment.py` +
`tests/test_analyst_status_only_regression.py` зелёные). `usage_comment` стал
deprecated-обёрткой, `artifact_links` теперь возвращает HTML-фрагменты
(breaking-change только для внутреннего вызова из удаляемого пути).
Документация обновлена: CHANGELOG.md (`Added` + `Changed`),
`docs/architecture/README.md` (новый подраздел «Plane Sync: единый
status-коммент агентов»), ADR-001 заведён в
`docs/work-items/ORCH-016/06-adr/`.
Прохождение тестов:
- 60 новых ORCH-016 тестов: PASS (TC-01…TC-23 покрывают AC-1…AC-14).
- TC-20 (`test_qg_registry_snapshot.py`) подтверждает: `QG_CHECKS` и
`STAGE_TRANSITIONS` бит-идентичны (AC-11).
- Полный прогон: 392 PASS, 4 FAIL (`tests/test_m6_sequence.py::*`,
`tests/test_plane_webhook.py::test_orchestrator_project_routes_to_orchestrator_repo`,
`tests/test_plane_webhook.py::test_prefixes_independent_per_project`).
Эти 4 фейла **предсуществуют на `main`** (проверено: `git checkout main --
src/ tests/` → те же 4 фейла; ORCH-016 их не индуцировал). AC-10 «no
regression» соблюдено.
Соответствие ТЗ (`02-trz.md`):
- §1 модули: тронуты строго заявленные (`usage.py`, `stage_engine.py`,
`agents/launcher.py`, новый `frontmatter.py`); `qg/checks.py` сознательно
не трогается (ADR-001 §5, alt-6).
- §2.1§2.5 формат, описания, verdict, ссылки, duration — реализовано.
- §3 API не меняется; §4 БД не меняется; §5 новых QG нет — подтверждено
TC-20.
- §6 docstrings, graceful frontmatter / duration, `fmt_duration` — чистая,
AC-13 happy + edge кейсы зелёные.
- §7 артефакты: ADR заведён.
- §8 документация: README архитектуры и CHANGELOG обновлены, `CLAUDE.md`
не трогается (правила не меняются).
- §9 запреты: `QG_CHECKS` / `STAGE_TRANSITIONS` / `add_comment` /
`_headers_for` / `PLANE_BOT_TOKENS` не тронуты; `--no-verify` не
использован.
Соответствие ADR-001:
- §1 единственный публичный `build_status_comment(...)` с указанной
сигнатурой ✓
- §2 описания per-agent ✓
- §3 `<sub>` тех-хвост ✓
- §4 русская метка `Длительность:`
- §5 `src/frontmatter.py`
- §6 `get_agent_duration` с указанным SQL ✓
- §7 HTML-якоря, `<br>` разделители ✓
- §8 `fmt_duration` контракт ✓
Self-hosting (ADR-001 «Последствия»): хелперы — чистый код, без рестарта
прод-контейнера; пройдёт стандартный staging-гейт.
## Findings
### P0 — Blocker
- Нет.
### P1 — Must fix
- Нет.
### P2 — Should fix
- Нет.
### P3 — Nice to have
- `src/usage.py` `_AGENT_DESCRIPTIONS` и встроенные строки в
`build_status_comment` (например, `"Длительность: " f"{d_text}"` и
`"Завершил " "архитектурную " "проработку. " "См. ADR ниже."`) разбиты
на множественные смежные литералы. Python склеит их корректно, но
читаемость страдает — рассмотреть однострочный литерал в follow-up.
- `03-acceptance-criteria.md` AC-3 формулирует пример как
`verdict: APPROVE`, тогда как канонический QG (`check_reviewer_verdict`,
`src/qg/checks.py:306`) ожидает строго `verdict: APPROVED`. На
отображение коммента это не влияет (билдер показывает то, что лежит
во frontmatter), но в самом AC лучше было бы зафиксировать тот же
термин, что в QG. Чинить артефакт стадии analysis из стадии review —
out-of-scope (правило: «не править артефакты других этапов»);
оставляю как заметку на follow-up для аналитика.
- `_post_usage_comments` для `deployer` всегда (включая
`deploy-staging`) дополнительно постит `task_summary_comment`. ТЗ §2.6
и AC-7 явно это не запрещают (саммари не считается status-комментом),
и `tests/test_post_usage_comments_integration.py::test_deployer_staging_picks_15_log`
это поведение фиксирует. Поведение работает, но смысловой саммари
«Итого по задаче» на staging-стадии (задача не завершена) — слегка
ранний. Кандидат на уточнение требований в отдельной задаче.
## Документация
- `CHANGELOG.md` — раздел `Unreleased` дополнен записями `Added` и
`Changed` с упоминанием ORCH-016, `build_status_comment`,
`fmt_duration`, `get_agent_duration`, `src/frontmatter.py` и
ссылки на ADR. ✓
- `docs/architecture/README.md` — добавлен подраздел «Plane Sync:
единый status-коммент агентов (ORCH-016)» с описанием формата
HTML-блока, источниками длительности и вердиктов, явным указанием,
что реестр гейтов и стадий не меняется. ✓
- `docs/work-items/ORCH-016/06-adr/ADR-001-unified-status-comment.md`
заведён, статус `Accepted`, покрывает все 5 открытых вопросов ТЗ
и пять альтернатив. ✓
- `CLAUDE.md` — правки не требовались (правила агентов и канон
документации без изменений), что и заявлено в ADR-001.
- `docs/architecture/internals.md` — упоминания про `usage.py` /
комменты не имеет, обновление не требуется (как и оговорено
ADR-001 §1).
Документация = golden source соблюдён: изменения в `src/` сопровождены
синхронным обновлением документации в том же PR.

View File

@@ -1,159 +0,0 @@
---
type: test-report
work_item_id: ORCH-016
verdict: PASS
result: PASS
version: 1
---
# Test Report — ORCH-016
## Окружение
- Python: 3.12.13
- pytest: 8.3.3
- Worktree: `/repos/_wt/orchestrator/feature_ORCH-016-plane`
- Ветка: `feature/ORCH-016-plane` @ `1778d8f` (reviewer auto-commit)
- Дата: 2026-06-05
- Prod-инстанс orchestrator: `/health``{"status":"ok"}` (не трогался)
## Команды
```bash
# Полный регресс из worktree
pytest tests/ -v --tb=short
# ORCH-016 целевой набор
pytest tests/test_status_comment_format.py \
tests/test_post_usage_comments_integration.py \
tests/test_status_comment_authorship.py \
tests/test_status_comment_dedup_regression.py \
tests/test_status_comment_duration_db_fallback.py \
tests/test_fmt_duration.py \
tests/test_qg_registry_snapshot.py \
tests/test_analyst_comment.py \
tests/test_analyst_comment_regression.py \
tests/test_analyst_status_only_regression.py \
tests/test_notify_done_regression.py -v
```
## Сводка
| Прогон | Passed | Failed | Skipped |
|--------|-------:|-------:|--------:|
| Полный (`tests/`) | **392** | **4** | 6 |
| ORCH-016 целевой (62 теста) | **62** | **0** | 0 |
## Smoke test API
| Endpoint | HTTP | Ответ |
|----------|------|-------|
| `GET /health` | 200 | `{"status":"ok","service":"orchestrator"}` |
| `GET /status` | 200 | JSON, активна задача `ORCH-016` (stage `testing`) |
| `GET /queue` | 200 | JSON, `counts={queued:0,running:1,done:36,failed:0}`, breaker `closed`, preflight OK |
## Покрытие плана тестов (`04-test-plan.yaml`)
| TC | Модуль | AC | Результат |
|----|--------|----|-----------|
| TC-01 | `test_status_comment_format.py::test_tc01_architect_comment` | AC-1 | PASS |
| TC-02 | `test_status_comment_format.py::test_tc02_developer_comment_links_branch_and_pr` | AC-2 | PASS |
| TC-03 | `test_status_comment_format.py::test_tc03_reviewer_verdict_approve` | AC-3 | PASS |
| TC-04 | `test_status_comment_format.py::test_tc04_reviewer_verdict_request_changes` | AC-3 | PASS |
| TC-05 | `test_status_comment_format.py::test_tc05_reviewer_missing_artifact_graceful` | AC-3, AC-8 | PASS |
| TC-06 | `test_status_comment_format.py::test_tc06_tester_pass` | AC-4 | PASS |
| TC-07 | `test_status_comment_format.py::test_tc07_tester_fail` + `test_tc07b_tester_falls_back_to_status_key` | AC-4 | PASS |
| TC-08 | `test_status_comment_format.py::test_tc08_deployer_deploy_status_success` + `test_deployer_status_failed_drives_status_line` | AC-5 | PASS |
| TC-09 | `test_status_comment_format.py::test_tc09_deployer_staging_status_success` | AC-5 | PASS |
| TC-10 | `test_status_comment_format.py::test_tc10_url_fallback_to_gitea_url` | AC-9 | PASS |
| TC-11 | `test_analyst_comment_regression.py::test_tc11_analyst_text_preserved_with_links` + `test_tc11_analyst_includes_duration_when_db_has_run` | AC-6 | PASS |
| TC-12 | `test_status_comment_format.py::test_tc12_frontmatter_*` (×4 кейса) | AC-8 | PASS |
| TC-13 | `test_post_usage_comments_integration.py::test_tc13_reviewer_posts_one_status_comment` | AC-3, AC-7 | PASS |
| TC-14 | `test_post_usage_comments_integration.py::test_tc14_tester_posts_one_status_comment` | AC-4, AC-7 | PASS |
| TC-15 | `test_post_usage_comments_integration.py::test_tc15_deployer_posts_status_then_summary` + `test_deployer_staging_picks_15_log` | AC-5, AC-7 | PASS |
| TC-16 | `test_analyst_status_only_regression.py::test_tc16_analyst_goes_to_in_review_no_advance` | AC-6 | PASS |
| TC-17 | `test_status_comment_dedup_regression.py::test_tc17_*` (×4) | AC-7 | PASS |
| TC-18 | `test_notify_done_regression.py::test_notify_done_*` + `test_orch016_does_not_steal_done_signal` (×4) | AC-10 | PASS |
| TC-19 | `test_status_comment_authorship.py::test_tc19_*` (×7) | AC-7 | PASS |
| TC-20 | `test_qg_registry_snapshot.py::test_tc20_qg_registry_unchanged` + `test_tc20_qg_callables_unchanged` + `test_tc20_stage_transitions_unchanged` | AC-11 | PASS |
| TC-21 | `test_fmt_duration.py::test_fmt_duration_boundary_table` | AC-13 | PASS |
| TC-22 | `test_fmt_duration.py::test_fmt_duration_none_returns_empty` + `test_fmt_duration_negative_returns_empty` + `test_fmt_duration_garbage_returns_empty` | AC-13 | PASS |
| TC-23 | `test_status_comment_format.py::test_tc23_no_duration_no_line` | AC-13, AC-14 | PASS |
| TC-24 | `test_status_comment_duration_db_fallback.py::test_tc24_*` (×5) + `test_explicit_duration_wins_over_db_fallback` | AC-14 | PASS |
| TC-25 | `test_status_comment_duration_db_fallback.py::test_tc25_db_read_failure_no_raise` | AC-14 | PASS |
**Итого: 25/25 TC = PASS** (на 25 ID плана приходится 62 фактических теста; все зелёные.)
## Сопоставление с критериями (`03-acceptance-criteria.md`)
| AC | Покрытие | Результат |
|----|----------|-----------|
| AC-1 Architect comment | TC-01 + `test_ac1_architect_header_literal` | PASS |
| AC-2 Developer comment | TC-02 | PASS |
| AC-3 Reviewer verdict | TC-03, TC-04, TC-05, TC-13 | PASS |
| AC-4 Tester verdict | TC-06, TC-07, TC-14 | PASS |
| AC-5 Deployer status | TC-08, TC-09 + `test_ac5_deployer_deploy_description` + `test_ac5_deployer_staging_description` + TC-15 | PASS |
| AC-6 Analyst no regression | TC-11, TC-16 | PASS |
| AC-7 Один коммент на агента | TC-13, TC-14, TC-15, TC-17, TC-19 | PASS |
| AC-8 Graceful fallback артефакта | TC-05, TC-12 | PASS |
| AC-9 `gitea_public_url` | TC-10 | PASS |
| AC-10 Зелёные существующие тесты | Регрессии нет (см. ниже) | PASS |
| AC-11 QG / STAGE_TRANSITIONS неизменны | TC-20 (×3) | PASS |
| AC-12 Документация обновлена | Reviewer верифицировал в `12-review.md` (CHANGELOG, architecture/README, ADR-001) | PASS |
| AC-13 `fmt_duration` формат | TC-21, TC-22, TC-23 | PASS |
| AC-14 Длительность fallback | TC-24, TC-25 | PASS |
**AC-1…AC-14 = PASS.**
## Анализ 4 фейлов в полном прогоне (AC-10)
```
FAILED tests/test_m6_sequence.py::test_created_uses_plane_sequence_id
FAILED tests/test_m6_sequence.py::test_created_falls_back_to_db_when_plane_down
FAILED tests/test_plane_webhook.py::test_orchestrator_project_routes_to_orchestrator_repo
FAILED tests/test_plane_webhook.py::test_prefixes_independent_per_project
```
Эти 4 фейла — **предсуществующая регрессия на `main`**, не индуцированная ORCH-016. Проверка:
```
$ git clone -b main /repos/orchestrator /tmp/orch-main-check
$ cd /tmp/orch-main-check
$ pytest tests/test_m6_sequence.py tests/test_plane_webhook.py
==================== 4 failed, 7 passed, 1 warning in 0.80s ====================
FAILED tests/test_m6_sequence.py::test_created_uses_plane_sequence_id
FAILED tests/test_m6_sequence.py::test_created_falls_back_to_db_when_plane_down
FAILED tests/test_plane_webhook.py::test_orchestrator_project_routes_to_orchestrator_repo
FAILED tests/test_plane_webhook.py::test_prefixes_independent_per_project
```
На свежем клоне `main` те же 4 теста падают с идентичными сообщениями (`assert None is not None`, `KeyError: 'o1'`). ORCH-016 не трогает `src/webhooks/plane.py`, `src/plane_sync.py::fetch_issue_sequence_id`, `src/projects.py` — то есть участки, ответственные за эти кейсы. Reviewer ранее зафиксировал тот же факт в `12-review.md`. **Регрессий, индуцированных ORCH-016 = 0** → AC-10 PASS.
Эти 4 фейла должны быть подняты отдельной задачей (вне scope ORCH-016).
## Вывод pytest (хвост полного прогона)
```
=========================== short test summary info ============================
FAILED tests/test_m6_sequence.py::test_created_uses_plane_sequence_id - asser...
FAILED tests/test_m6_sequence.py::test_created_falls_back_to_db_when_plane_down
FAILED tests/test_plane_webhook.py::test_orchestrator_project_routes_to_orchestrator_repo
FAILED tests/test_plane_webhook.py::test_prefixes_independent_per_project - K...
============ 4 failed, 392 passed, 6 skipped, 13 warnings in 7.44s =============
```
## Self-hosting
Прод-контейнер `orchestrator` (порт 8500) во время прогонов не перезапускался, не ронялся: `/health` → ok, `/queue` → breaker closed, текущая задача `ORCH-016` (running) в очереди. Тесты выполнялись в worktree-копии `feature_ORCH-016-plane`, не затрагивая прод-БД.
## Итог
**PASS.**
- Все 25 TC из `04-test-plan.yaml` = PASS (62 фактических теста зелёные).
- Все 14 AC из `03-acceptance-criteria.md` = PASS.
- Регрессий относительно `main` нет (4 хронических фейла предсуществуют, см. выше).
- Smoke test API зелёный.
- Прод-инстанс не задет.
Задача готова к стадии `deploy-staging`.

View File

@@ -1,145 +0,0 @@
---
deploy_status: SUCCESS
timestamp: 2026-06-05T12:51:07Z
work_item: ORCH-016
branch: feature/ORCH-016-plane
commit: d4b02ef728521776ac13dbed39ac64a758d9de54
target_service: orchestrator
target_port: 8500
deploy_mode: artifact-only
prod_container_restarted: false
---
# Deploy Log — ORCH-016
## Verdict
**`deploy_status: SUCCESS`** — артефактный (artifact-only) деплой-вердикт.
Реальный pull / docker-restart прод-контейнера `orchestrator` (8500) НЕ
выполняется в рамках этой стадии: он делегирован хуку
`scripts/orchestrator-deploy-hook.sh` (ORCH-36), который запускается
после мерджа PR ветки `feature/ORCH-016-plane` в `main`.
## Pre-conditions (все ✓)
| Артефакт | Поле | Значение |
|----------|------|----------|
| `12-review.md` | `verdict` | `APPROVED` |
| `13-test-report.md` | `verdict` | `PASS` |
| `15-staging-log.md` | `staging_status` | `SUCCESS` (10/10 staging-checks) |
| `04-test-plan.yaml` | — | покрывает AC-1…AC-14 |
| ADR | `06-adr/ADR-001-*` | заведён |
| CHANGELOG.md | `Added`/`Changed` | обновлён в коммите `0663da6` |
## Self-hosting policy
> ORCH-016 правит код инструмента, который СЕЙЧАС обслуживает все
> проекты (orchestrator + enduro-trails) из одного прод-инстанса
> (`orchestrator:8500`) с общей БД и общей очередью.
Поэтому:
1. **Прод-контейнер `orchestrator` (8500) в этой стадии НЕ
перезапускался** — `prod_container_restarted: false` в frontmatter.
Это прямое требование `CLAUDE.md` (раздел "Self-hosting") и
`docs/operations/INFRA.md`.
2. Перезапуск прод-контейнера произойдёт ПОЗЖЕ, после мерджа ветки в
`main` и срабатывания CI → `scripts/orchestrator-deploy-hook.sh`.
3. Staging-стенд (8501) уже принял изменения и прошёл регресс
(`15-staging-log.md`, 10/10 checks) — это и есть страховка перед
прод-деплоем self.
## Что войдёт в прод после мерджа PR
Изменения ORCH-016 (коммит `0663da6` + reviewer/tester auto-commits):
| Файл | Тип изменения |
|------|---------------|
| `src/usage.py` | расширен `build_status_comment(...)`: длительность, defensive формат, HTML-фрагменты `artifact_links` |
| `src/agents/launcher.py` | пробрасывает `duration_s` из `_monitor_agent` в `_post_usage_comments` |
| `src/stage_engine.py` | для analyst-стадии — DB-fallback `usage.get_agent_duration(task_id, agent)` |
| `src/frontmatter.py` | defensive `read_frontmatter_value(...)` |
| `tests/test_status_comment_*.py` и др. | 60 новых тестов TC-01…TC-23 (PASS) |
| `docs/architecture/README.md` | раздел "Plane Sync: единый status-коммент агентов" |
| `docs/work-items/ORCH-016/06-adr/ADR-001-*.md` | ADR ORCH-016 |
| `CHANGELOG.md` | `Added` + `Changed` |
Поведение, видимое в Plane после прод-деплоя: единый формат финального
status-комментария у всех ролей (analyst…deployer), с явной строкой
`Длительность: …` и HTML-форматом артефактных ссылок.
## Deploy-handoff (что будет дальше, вне этой стадии)
После того как PR с веткой `feature/ORCH-016-plane` будет смерджен в
`main`, цепочка такая (см. `scripts/orchestrator-deploy-hook.sh`):
```
PR merge to main
└─► Gitea Actions (CI)
└─► orchestrator-deploy-hook.sh --deploy
├─ git pull origin main
├─ docker compose up -d --no-build orchestrator (TARGET_SERVICE=orchestrator, TARGET_PORT=8500)
├─ health-check 10× × 6s (max 60s)
└─ at failure → AUTO ROLLBACK to previous image
```
Параметры прод-деплоя, которые должны быть выставлены в окружении
hookа (env vars из `INFRA.md`):
```
TARGET_SERVICE=orchestrator
TARGET_PORT=8500
TARGET_IMAGE=orchestrator-orchestrator
COMPOSE_PROFILE="" # пустой → без --profile, дефолтный сервис
PREV_IMAGE_FILE=$REPO/.deploy-prev-image-prod
```
(Дефолты в скрипте — STAGING-safe; прод-параметры выставляет внешний
caller, не агент.)
Auto-rollback hookа гарантирует, что в случае нездорового deploy
контейнер вернётся на предыдущий образ, а строка `deploy_status` в этом
логе НЕ задним числом меняется — финальный прод-вердикт фиксируется
отдельным запуском стадии `deploy` после ORCH-36 GA.
## Команды (только read-only проверки, ничего не запускалось)
```bash
# 1. Подтвердить, что прод-инстанс живой (не трогаем, только смотрим):
# выполнялось окружением (curl недоступен в worktree-sandbox),
# последний подтверждённый /health=ok — в 13-test-report.md.
# 2. Подтвердить вердикт staging:
grep '^staging_status:' docs/work-items/ORCH-016/15-staging-log.md
# → staging_status: SUCCESS
# 3. Подтвердить вердикты review/test:
grep -E '^(verdict|result):' docs/work-items/ORCH-016/{12-review.md,13-test-report.md}
# → 12-review.md:verdict: APPROVED
# → 13-test-report.md:verdict: PASS
# → 13-test-report.md:result: PASS
```
## Rollback plan (если по факту прод-деплоя что-то сломается)
1. Hook сам делает auto-rollback (см. `do_rollback()` в
`orchestrator-deploy-hook.sh`).
2. Ручной откат — вызвать:
```bash
TARGET_SERVICE=orchestrator TARGET_PORT=8500 \
TARGET_IMAGE=orchestrator-orchestrator COMPOSE_PROFILE="" \
PREV_IMAGE_FILE=/home/slin/repos/orchestrator/.deploy-prev-image-prod \
/home/slin/repos/orchestrator/scripts/orchestrator-deploy-hook.sh --rollback
```
3. Точка отката: предыдущий running image, сохранённый в
`.deploy-prev-image-prod` ДО `docker compose up`.
## Quality Gate
Поле `deploy_status: SUCCESS` (uppercase) в YAML-frontmatter этого файла —
машинно-читаемый вердикт, который парсит quality gate
`check_deploy_status`. Никакая проза в теле логa не учитывается.
---
*Stage: `deploy`. Финальная стадия конвейера. Следующий шаг — `done` (закрывается CI / финальной стадией, не агентом). Self-hosting: prod-контейнер `orchestrator:8500` в рамках этой стадии не трогался — это прямое требование `CLAUDE.md`.*

View File

@@ -1,7 +0,0 @@
# Business Request: Прямые ссылки на BRD и Plane-таску в Telegram-уведомлении об апруве
Work Item ID: ORCH-017
## Description
TBD

View File

@@ -1,91 +0,0 @@
# 01-BRD — ORCH-017: Прямые ссылки на BRD и Plane-таску в Telegram-уведомлении об апруве
Work Item: **ORCH-017**
Repo: `orchestrator` · Branch: `feature/ORCH-017-brd-plane-telegram`
Тип: косметическая правка (UX уведомлений). Парная с ORCH-016.
## 1. Бизнес-контекст и проблема
Когда оркестратор завершает стадию `analysis` и просит подтвердить BRD, в Telegram уходит
отдельное «пингующее» уведомление (`notify_approve_requested` в `src/notifications.py`).
Сейчас в этом сообщении **нет ссылок**: владелец (Слава) вынужден вручную зайти в Plane,
найти нужную issue, открыть комментарий аналитика, оттуда перейти к BRD-документу. Это
лишние ручные шаги на каждой задаче.
Текущий текст уведомления:
> 📋 {WI}: BRD/ТЗ/AC готовы. Переведите задачу в статус Approved в Plane для продолжения.
## 2. Цель
В **этом же** уведомлении дать две прямые кликабельные ссылки, чтобы весь сценарий
прохождения апрува выполнялся из Telegram, без ручной навигации в Plane:
1. **Ссылка на BRD** — открывает `01-brd.md` в Gitea (прочитать документ).
2. **Ссылка на Plane-issue** — открывает задачу в Plane (перевести в Approved / отклонить с комментом).
## 3. Целевой сценарий (Слава)
Получил уведомление → кликнул «📄 BRD» → прочитал → кликнул «✅ Задача» → перевёл в
Approved (или отклонил с комментарием). Всё из Telegram.
## 4. Объём (Scope)
### В объёме (выбранный по умолчанию минимальный вариант — см. §8 открытые вопросы)
- Доработка **только** функции `notify_approve_requested(task_id)` в `src/notifications.py`
(стадия `analysis`, запрос статуса Approved).
- Формирование двух ссылок и встраивание их в текст того же отдельного уведомления.
- Формат — HTML-ссылки в тексте (`<a href="…">label</a>`), т.к. `send_telegram` уже шлёт
`parse_mode="HTML"`. Альтернатива (inline-кнопки) — открытый вопрос §8.
- Новая конфиг-настройка для внешнего web-URL Plane (см. §6, риск №1).
- Обновление документации (`CLAUDE.md` env-карта при необходимости, `CHANGELOG.md`,
`.env.example`) в том же PR.
### Вне объёма (НЕ трогать)
- Логика апрува: `:approved:`-handler, `check_analysis_approved`, переходы стадий.
- Живой Telegram-трекер (`update_task_tracker` / `render_task_tracker`, PR #21/#22) — его
текст и поведение не меняем; новое уведомление остаётся ОТДЕЛЬНЫМ сообщением, дубли
трекера не создаём.
- Содержимое комментариев в Plane (это смежная задача ORCH-016).
- Ссылки в других уведомлениях (deploy-failed, agent-failed, error) — вне объёма по
умолчанию (см. открытый вопрос §8.2).
## 5. Заинтересованные стороны
- **Owner / получатель уведомления:** Слава.
- **Поставщик данных:** оркестратор (БД `tasks`: repo, branch, work_item_id, plane_issue_id).
## 6. Функциональные требования
| # | Требование |
|---|------------|
| FR-1 | Уведомление об апруве BRD содержит кликабельную ссылку на документ `docs/work-items/<WI>/01-brd.md` в Gitea. |
| FR-2 | То же уведомление содержит кликабельную ссылку на соответствующую Plane-issue. |
| FR-3 | Существующий текст-призыв («Переведите задачу в статус Approved …») сохраняется. |
| FR-4 | Уведомление остаётся ОДНИМ отдельным пингующим сообщением (без дублей, без второго сообщения). |
| FR-5 | Ссылка на BRD строится на внешнем `gitea_public_url` (фоллбэк `gitea_url`), формат branch-view: `{base}/{owner}/{repo}/src/branch/{branch}/docs/work-items/{WI}/01-brd.md`. Переиспользовать существующий паттерн из `src/usage.py`. |
| FR-6 | Ссылка на Plane-issue строится на внешнем web-URL Plane + workspace + project + issue. |
## 7. Нефункциональные требования
| # | Требование |
|---|------------|
| NFR-1 | **Никогда не ронять оркестратор** из-за уведомления: построение ссылок обёрнуто в защиту, при отсутствии данных (нет branch / нет plane_issue_id / не задан web-URL) — сообщение всё равно отправляется, просто без соответствующей ссылки (graceful degradation). |
| NFR-2 | Не нарушать self-hosting: правка не требует рестарта прод-контейнера сверх обычного деплоя; не меняет реестр гейтов/стадий. |
| NFR-3 | Сохранить `parse_mode="HTML"`; экранировать динамические подписи (`html.escape`), URL формировать из доверенных конфиг-значений. |
## 8. Открытые вопросы (требуют решения Owner; в документах принят безопасный дефолт)
1. **Формат ссылок.** Дефолт BRD: HTML-ссылки в тексте (минимальная правка). Альтернатива —
inline-кнопки «📄 Открыть BRD» / «✅ К задаче в Plane», что требует доработки `send_telegram`
(параметр `reply_markup`/`inline_keyboard`). → решение к стадии architecture.
2. **Охват.** Дефолт: только BRD-апрув (`notify_approve_requested`). Альтернатива — все точки,
требующие решения Славы (напр. согласование макета ORCH-14). → если «все точки», объём
расширяется, нужен отдельный перечень событий.
3. **Внешний web-URL Plane.** В конфиге сейчас только внутренний `plane_api_url`
(`http://localhost:8091`) — он НЕ годится для браузерной ссылки. Дефолт: завести новую
env-настройку `ORCH_PLANE_WEB_URL` (внешний адрес Plane) с фоллбэком на `plane_api_url`.
Точное значение URL должен подтвердить Owner/INFRA.
4. **Формат Plane-ссылки.** `…/{workspace}/projects/{project_id}/issues/{issue_id}/` (надёжно,
issue_id есть в `tasks.plane_issue_id`) vs короткий `…/{workspace}/browse/<IDENT>/`
(зависит от соответствия `work_item_id` ↔ Plane identifier, что не гарантировано из-за
zero-padding ORCH-017 vs ORCH-17). → решение к стадии architecture.
## 9. Зависимости и связки
- **PR #14** — `gitea_public_url`: переиспользуем для кликабельных ссылок на доки.
- **PR #21/#22** — живой Telegram-трекер: новое сообщение остаётся отдельным, трекер не трогаем.
- **ORCH-016** — единые коммент-артефакты в Plane (парная задача про навигацию к документам).
## 10. Критерий бизнес-успеха
Слава из одного Telegram-уведомления одним кликом открывает BRD и одним кликом — задачу в
Plane, не заходя в Plane вручную и не ища комментарий.

View File

@@ -1,87 +0,0 @@
# 02-ТЗ — ORCH-017: Прямые ссылки в Telegram-уведомлении об апруве BRD
Work Item: **ORCH-017** · Repo: `orchestrator`
Опирается на 01-brd.md. Уточняет конкретные изменения кода/конфигурации.
> Примечание по канону: ТЗ фиксирует ТРЕБОВАНИЯ к изменениям, а не готовое
> архитектурное решение. Выбор формата (текст vs inline-кнопки) и точного формата
> Plane-URL — за стадией architecture (см. открытые вопросы 01-brd.md §8). Если по
> ходу разработки ТЗ окажется неполным/неверным — возврат на стадию Анализ, без
> правок ТЗ задним числом.
## 1. Задействованные модули `src/`
| Модуль | Роль в задаче |
|--------|---------------|
| `src/notifications.py` | **Основной.** Функция `notify_approve_requested(task_id)` (≈ строки 547566) — единственная точка отправки пингующего уведомления об апруве BRD. Сюда добавляются ссылки. |
| `src/config.py` | Класс `Settings`. Добавить настройку внешнего web-URL Plane (`plane_web_url`, env `ORCH_PLANE_WEB_URL`) с дефолтом-фоллбэком. |
| `src/projects.py` | (Чтение) `get_project_by_repo(repo)``plane_project_id` для построения Plane-URL. |
| `src/usage.py` | (Референс, не править) Эталонный паттерн branch-view ссылки на доки (`{base}/{owner}/{repo}/src/branch/{branch}/<rel>`), строки ≈483503 — переиспользовать тот же формат. |
| `src/db.py` | (Чтение) Таблица `tasks`: поля `work_item_id`, `repo`, `branch`, `plane_issue_id`. Источник данных для ссылок. |
## 2. Источники данных (из `tasks` по `task_id`)
- `work_item_id` — путь к BRD-документу и (опц.) идентификатор issue.
- `repo`, `branch` — построение Gitea branch-view URL.
- `plane_issue_id` — uuid issue в Plane для прямой ссылки.
- `project_id` — через `projects.get_project_by_repo(repo).plane_project_id`.
`notify_approve_requested` сейчас принимает только `task_id` и тянет лишь `work_item_id`
через `_get_work_item_id`. Требуется дополнительно прочитать `repo`, `branch`,
`plane_issue_id` из `tasks` (один SELECT, в защищённом try/except).
## 3. Требуемые изменения
### 3.1 `src/notifications.py`
- Построить **BRD-ссылку** (FR-1/FR-5):
`{base}/{owner}/{repo}/src/branch/{branch}/docs/work-items/{work_item_id}/01-brd.md`,
где `base = (settings.gitea_public_url or settings.gitea_url).rstrip('/')`,
`owner = settings.gitea_owner`. Если нет `base`/`repo`/`branch`/`work_item_id` — ссылку
опустить (NFR-1).
- Построить **Plane-ссылку** (FR-2/FR-6):
`{plane_web_base}/{workspace_slug}/projects/{project_id}/issues/{plane_issue_id}/`
(точный формат — решение architecture, см. 01-brd §8.4). Если нет данных — опустить.
- Встроить обе ссылки в текст того же сообщения (FR-3/FR-4), формат HTML-`<a>` по умолчанию.
Сохранить существующий призыв «Переведите задачу в статус Approved …».
- Сохранить вызов как **одно** `send_telegram(msg)` (пингующее, не silent). Порядок
существующих действий не менять: старт BRD-часов (`mark_brd_review_started`) →
`update_task_tracker(task_id)``send_telegram(msg)`.
- Динамические подписи экранировать `html.escape` (NFR-3).
### 3.2 `src/config.py`
- Добавить в `Settings` поле `plane_web_url: str = ""` (env `ORCH_PLANE_WEB_URL`).
- Семантика фоллбэка: `plane_web_base = (settings.plane_web_url or settings.plane_api_url).rstrip('/')`.
### 3.3 Опционально (если выбран вариант inline-кнопок — открытый вопрос 01-brd §8.1)
- Расширить `send_telegram(text, disable_notification=False, reply_markup=None)`:
при наличии `reply_markup` прокидывать его в payload `sendMessage`. Обратная
совместимость — обязательна (текущие вызовы без аргумента работают как раньше).
- ⚠️ Это РАСШИРЯЕТ объём; включается только по явному решению Owner на стадии architecture.
## 4. Изменения API
Нет. Публичные HTTP-эндпоинты (`/webhook/*`, `/status`, `/queue`, `/health`) не затрагиваются.
## 5. Изменения схемы БД
Нет. Все нужные поля (`repo`, `branch`, `work_item_id`, `plane_issue_id`) уже существуют в `tasks`.
## 6. Изменения конфигурации / окружения
- Новая env-переменная `ORCH_PLANE_WEB_URL` (внешний web-адрес Plane). Прописать в
`.env.example` (канон секретов/настроек), описать в env-карте (`CLAUDE.md` /
`docs/operations/INFRA.md`). Реальное значение задаётся в `.env`/`.env.staging` на хосте.
- Существующие `ORCH_GITEA_PUBLIC_URL`, `ORCH_GITEA_OWNER`, `ORCH_PLANE_WORKSPACE_SLUG`
переиспользуются как есть.
## 7. Требования к новым QG checks
Нет. Реестр `QG_CHECKS`, стадии и машинные вердикты не меняются (правка — отображение,
не управление конвейером).
## 8. Артефакты pipeline, которые должны быть обновлены в ЭТОМ PR
- `CHANGELOG.md` — запись о фиче.
- `.env.example` — новая `ORCH_PLANE_WEB_URL`.
- При добавлении настройки — env-карта в `CLAUDE.md` / `docs/operations/INFRA.md`.
- ADR (стадия architecture): `docs/work-items/ORCH-017/06-adr/ADR-001-*.md` — фиксирует выбор
формата (текст vs кнопки) и формат Plane-URL.
## 9. Ограничения
- Не трогать `:approved:`-handler и `check_analysis_approved` (только текст/формат уведомления).
- Не плодить сообщения: одно отдельное пингующее сообщение; живой трекер (PR #21/#22) не дублировать.
- Соблюдать self-hosting: не ронять/не рестартить прод сверх штатного деплоя; обязательная
страховка `deploy-staging` (8501) перед прод-деплоем орка.

View File

@@ -1,64 +0,0 @@
# 03-Acceptance Criteria — ORCH-017
Work Item: **ORCH-017** · Repo: `orchestrator`
Каждый критерий формулирует условие PASS/FAIL. Источник — 01-brd.md / 02-trz.md.
## AC-1 — Ссылка на BRD присутствует в уведомлении
- **PASS:** Текст, сформированный `notify_approve_requested`, содержит кликабельную ссылку
на `docs/work-items/<WI>/01-brd.md` вида
`{gitea_public_url|gitea_url}/{owner}/{repo}/src/branch/{branch}/docs/work-items/{WI}/01-brd.md`.
- **FAIL:** Ссылки на BRD нет, либо она ведёт не на `01-brd.md`/не на нужный WI.
## AC-2 — Ссылка на Plane-issue присутствует в уведомлении
- **PASS:** Тот же текст содержит кликабельную ссылку на issue в Plane, построенную на
внешнем web-URL Plane + workspace + project + `plane_issue_id` (или согласованный браузер-формат).
- **FAIL:** Ссылки на issue нет, либо она указывает на внутренний `localhost`/неверную issue.
## AC-3 — Базовый URL берётся из внешних настроек
- **PASS:** BRD-ссылка использует `gitea_public_url`, при его пустоте — `gitea_url`; Plane-ссылка
использует `plane_web_url` (env `ORCH_PLANE_WEB_URL`), при пустоте — `plane_api_url`.
- **FAIL:** Захардкожен хост, либо ссылка нерабочая снаружи деплой-хоста.
## AC-4 — Существующий призыв сохранён
- **PASS:** Текст по-прежнему содержит призыв перевести задачу в статус Approved (смысл строки
«Переведите задачу в статус Approved … для продолжения» сохранён).
- **FAIL:** Призыв удалён/искажён.
## AC-5 — Одно отдельное пингующее сообщение, без дублей
- **PASS:** `notify_approve_requested` отправляет ровно одно сообщение через `send_telegram`
(пингующее, не silent). Живой трекер (`update_task_tracker`) обновляется как раньше и не
дублируется новым сообщением.
- **FAIL:** Появляется второе/дубль-сообщение, либо трекер шлётся повторно как новое сообщение.
## AC-6 — Graceful degradation (никогда не ронять оркестратор)
- **PASS:** При отсутствии `branch` / `plane_issue_id` / незаданном Plane web-URL функция НЕ
бросает исключение: уведомление уходит с доступными ссылками (или без отсутствующей), орк жив.
- **FAIL:** Отсутствие данных приводит к исключению/падению потока уведомлений.
## AC-7 — HTML-безопасность
- **PASS:** Сохранён `parse_mode="HTML"`; динамические подписи экранируются (`html.escape`),
URL валиден и не ломает разметку сообщения.
- **FAIL:** Сообщение приходит с битой HTML-разметкой или с неэкранированным пользовательским текстом.
## AC-8 — Логика апрува не затронута
- **PASS:** `:approved:`-handler, `check_analysis_approved`, переходы стадий и реестр `QG_CHECKS`
без изменений; правка касается только текста/формата уведомления.
- **FAIL:** Изменена логика гейта/перехода стадий.
## AC-9 — Документация обновлена в том же PR
- **PASS:** Обновлены `CHANGELOG.md` и `.env.example` (новая `ORCH_PLANE_WEB_URL`); если добавлена
настройка — отражено в env-карте (`CLAUDE.md`/`docs/operations/INFRA.md`); заведён ADR на
выбранный формат. (Reviewer проверяет доку → нет обновления = REQUEST_CHANGES.)
- **FAIL:** Код изменён, документация — нет.
## AC-10 — Тесты зелёные
- **PASS:** Новые/затронутые тесты (`tests/test_notify_approve_links.py` и существующие
`tests/test_telegram_tracker.py`, `tests/test_notify_done_regression.py`) проходят; `pytest tests/ -q` зелёный.
- **FAIL:** Любой связанный тест падает.
---
### Зависит от решений Owner (open questions 01-brd §8)
- Если выбран вариант **inline-кнопок** — AC-1/AC-2 считаются выполненными при наличии кнопок
«📄 Открыть BRD» / «✅ К задаче в Plane» с теми же URL; дополнительно AC: обратная совместимость
`send_telegram` (старые вызовы без `reply_markup` работают).
- Если охват расширен до **всех точек решения** — AC-1/AC-2 проверяются для каждой такой точки.

View File

@@ -1,99 +0,0 @@
work_item: ORCH-017
title: "Прямые ссылки на BRD и Plane-таску в Telegram-уведомлении об апруве"
notes: >
Тесты изолируют сеть: send_telegram/httpx мокируются, проверяется СФОРМИРОВАННЫЙ текст
(и/или reply_markup, если выбран вариант кнопок), а не реальная отправка. БД tasks
наполняется фикстурой (work_item_id, repo, branch, plane_issue_id). Маппинг на критерии — в поле acceptance.
tests:
- id: TC-01
type: unit
description: "notify_approve_requested формирует текст с кликабельной ссылкой на 01-brd.md (Gitea branch-view)"
module: tests/test_notify_approve_links.py
setup: "task в tasks с work_item_id=ORCH-017, repo=orchestrator, branch=feature/ORCH-017-..., gitea_public_url задан; send_telegram замокан"
expected: PASS
acceptance: [AC-1, AC-3]
- id: TC-02
type: unit
description: "Текст содержит ссылку на Plane-issue с внешним web-URL + workspace + project + plane_issue_id"
module: tests/test_notify_approve_links.py
setup: "plane_web_url(ORCH_PLANE_WEB_URL) и workspace заданы; project резолвится по repo; plane_issue_id в tasks"
expected: PASS
acceptance: [AC-2, AC-3]
- id: TC-03
type: unit
description: "При пустом gitea_public_url BRD-ссылка строится на gitea_url (фоллбэк); при пустом plane_web_url — на plane_api_url"
module: tests/test_notify_approve_links.py
expected: PASS
acceptance: [AC-3]
- id: TC-04
type: unit
description: "Сохранён призыв перевести задачу в статус Approved (подстрока 'Approved' присутствует)"
module: tests/test_notify_approve_links.py
expected: PASS
acceptance: [AC-4]
- id: TC-05
type: unit
description: "send_telegram вызван ровно один раз (пингующее сообщение), без disable_notification=True"
module: tests/test_notify_approve_links.py
setup: "mock send_telegram, assert call_count == 1 и аргумент disable_notification не True"
expected: PASS
acceptance: [AC-5]
- id: TC-06
type: unit
description: "Graceful: branch=None / plane_issue_id=None — функция не бросает исключение, сообщение всё равно отправляется"
module: tests/test_notify_approve_links.py
setup: "task без branch и без plane_issue_id; убедиться что send_telegram всё равно вызван, отсутствующая ссылка опущена"
expected: PASS
acceptance: [AC-6]
- id: TC-07
type: unit
description: "Plane web-URL не задан и plane_api_url пуст — Plane-ссылка опускается, BRD-ссылка остаётся, орк не падает"
module: tests/test_notify_approve_links.py
expected: PASS
acceptance: [AC-6]
- id: TC-08
type: unit
description: "Сохранён parse_mode=HTML; динамические подписи экранированы, HTML-разметка ссылок валидна"
module: tests/test_notify_approve_links.py
expected: PASS
acceptance: [AC-7]
- id: TC-09
type: unit
description: "Регрессия трекера: update_task_tracker по-прежнему работает (silent edit), новое сообщение его не дублирует"
module: tests/test_telegram_tracker.py
expected: PASS
acceptance: [AC-5, AC-8]
- id: TC-10
type: integration
description: "Поток analysis-approved: _handle_analysis_approved_flow при готовых артефактах вызывает notify_approve_requested; БД tasks даёт корректные repo/branch/plane_issue_id для ссылок"
module: tests/test_analysis_approve_flow_links.py
setup: "замокать сетевые вызовы Plane/Gitea/Telegram; убедиться, что check_analysis_approved/переходы стадий не изменены"
expected: PASS
acceptance: [AC-1, AC-2, AC-8]
# Условные тесты — включаются ТОЛЬКО если Owner выбрал вариант inline-кнопок (01-brd §8.1)
- id: TC-11
type: unit
description: "(Условный) Вариант кнопок: payload содержит reply_markup.inline_keyboard с кнопками '📄 Открыть BRD' и '✅ К задаче в Plane' с верными url"
module: tests/test_notify_approve_links.py
expected: PASS
condition: "only if inline-buttons variant chosen"
acceptance: [AC-1, AC-2]
- id: TC-12
type: unit
description: "(Условный) Обратная совместимость send_telegram: вызовы без reply_markup работают как раньше (payload без поля reply_markup)"
module: tests/test_telegram_tracker.py
expected: PASS
condition: "only if inline-buttons variant chosen"
acceptance: [AC-5]

View File

@@ -1,117 +0,0 @@
# ADR-001: Прямые ссылки в Telegram-уведомлении об апруве BRD (формат и Plane-URL)
Work Item: **ORCH-017** · Repo: `orchestrator` · Стадия: architecture
Тип: per-work-item ADR (НЕ сквозной — реестр гейтов/стадий/компонентов не меняется).
## Статус
Accepted
## Контекст
BRD (`01-brd.md`) и ТЗ (`02-trz.md`) требуют добавить в пингующее уведомление об апруве
BRD (`notify_approve_requested(task_id)` в `src/notifications.py`) две кликабельные ссылки:
на документ `01-brd.md` в Gitea и на Plane-issue. ТЗ намеренно оставило за стадией
architecture три развилки (открытые вопросы `01-brd.md` §8):
1. **§8.1 — формат ссылок:** HTML-`<a>` в тексте (минимум) **vs** inline-кнопки
(`reply_markup` в `send_telegram`).
2. **§8.4 — формат Plane-URL:** полный путь `.../projects/{project_id}/issues/{issue_id}/`
**vs** короткий `.../browse/<IDENT>/`.
3. **§8.3 — внешний web-URL Plane:** в конфиге есть только внутренний `plane_api_url`
(`http://localhost:8091`), непригодный для браузерной ссылки.
Жёсткое ограничение контекста — **self-hosting**: правка живёт в инструменте, который сейчас
обслуживает другие проекты из общего прод-контейнера. Любое расширение blast radius
(особенно правка разделяемой функции `send_telegram`, которой пользуется и живой трекер
PR #21/#22) — групповой риск. Поэтому из равноценных вариантов выбирается тот, что меняет
меньше кода и не трогает общие точки.
Фактическое состояние кода, проверенное на ветке:
- `send_telegram(text, disable_notification=False)` (`src/notifications.py:42`) шлёт
`parse_mode="HTML"` — HTML-`<a>` работает без изменения сигнатуры.
- Эталон branch-view ссылки на доки — `src/usage.py:455-458`:
`base = (gitea_public_url or gitea_url).rstrip('/')`, `owner = gitea_owner`,
URL `{base}/{owner}/{repo}/src/branch/{branch}/<rel>`.
- Plane-issue uuid надёжно лежит в `tasks.plane_issue_id`; `project_id` берётся через
`projects.get_project_by_repo(repo).plane_project_id`.
- В `plane_sync.py` строки `.../workspaces/{slug}/projects/{pid}/issues/{id}/` — это **API**
путь (`{plane_api_url}/api/v1/...`), НЕ браузерный. Браузерный роут Plane —
`{web_base}/{workspace_slug}/projects/{project_id}/issues/{issue_id}` (без `/api/v1`,
без сегмента `/workspaces/`).
## Решение
### Р-1 (§8.1) — HTML-ссылки в тексте. Inline-кнопки отклонены.
Ссылки встраиваются как `<a href="…">подпись</a>` в текст того же одного сообщения.
**`send_telegram` НЕ трогаем** (сигнатура без `reply_markup`). Inline-кнопки потребовали бы
правки разделяемой функции, которой пользуется живой трекер, — это рост blast radius без
бизнес-выгоды для одной точки уведомления. Расширение до кнопок — **вне объёма ORCH-017**;
при реальной потребности заводится отдельный work item.
### Р-2 (§8.4) — полный путь Plane-issue по uuid. Короткий `browse/<IDENT>` отклонён.
Формат:
```
{plane_web_base}/{workspace_slug}/projects/{project_id}/issues/{plane_issue_id}/
```
Источники: `plane_web_base` (Р-3), `workspace_slug = settings.plane_workspace_slug`,
`project_id = get_project_by_repo(repo).plane_project_id`, `plane_issue_id = tasks.plane_issue_id`.
Короткий `browse/<IDENT>` отклонён: он опирается на совпадение `work_item_id` с Plane-identifier,
которое не гарантировано из-за zero-padding (`ORCH-017` в БД vs `ORCH-17` как identifier).
uuid в `plane_issue_id` — детерминированный и уже в наличии источник.
### Р-3 (§8.3) — новая настройка `ORCH_PLANE_WEB_URL` + loopback-guard.
В `src/config.py` добавляется `plane_web_url: str = ""` (env `ORCH_PLANE_WEB_URL`).
База резолвится как:
```python
plane_web_base = (settings.plane_web_url or settings.plane_api_url).rstrip("/")
```
**Loopback-guard (разрешение конфликта AC-2 ↔ AC-3):** дефолт-фоллбэк `plane_api_url` равен
`http://localhost:8091` и снаружи хоста не кликается. Поэтому: если итоговый `plane_web_base`
указывает на loopback/локальный хост (`localhost`, `127.0.0.1`, `0.0.0.0`, `[::1]`) **или**
пуст — **Plane-ссылка опускается целиком** (а не вставляется битой). Так одновременно:
AC-2 (не выпускаем localhost-ссылку), AC-3 (цепочка фоллбэка соблюдена как попытка),
AC-6/NFR-1 (никаких исключений, сообщение уходит без отсутствующей ссылки).
### Р-4 — graceful degradation как контракт построения ссылок.
Чтение `repo/branch/plane_issue_id` из `tasks` — один SELECT в `try/except`. Каждая из двух
ссылок строится независимо; при нехватке данных конкретная ссылка опускается, призыв
«Переведите задачу в статус Approved …» и само сообщение сохраняются всегда. Динамические
подписи — через `html.escape`; URL формируются только из доверенных конфиг/БД-значений.
### Р-5 — инвариант «одно сообщение, без дублей».
Порядок действий в `notify_approve_requested` сохраняется: `mark_brd_review_started`
`update_task_tracker(task_id)` → один `send_telegram(msg)` (пингующий, не silent). Живой
трекер не дублируется. Реестр `QG_CHECKS`, стадии, `:approved:`-handler,
`check_analysis_approved` — без изменений (правка — отображение, не управление конвейером).
## Затронутые модули (для стадии development)
| Модуль | Изменение |
|--------|-----------|
| `src/notifications.py` | `notify_approve_requested`: SELECT `repo/branch/plane_issue_id`; сборка двух ссылок (Р-2/Р-3/Р-4); встраивание в текст. |
| `src/config.py` | `Settings.plane_web_url: str = ""` (env `ORCH_PLANE_WEB_URL`). |
| `src/projects.py` | (чтение) `get_project_by_repo(repo).plane_project_id`. |
| `src/usage.py` | (референс, НЕ править) паттерн branch-view URL. |
| `.env.example`, `CHANGELOG.md`, env-карта (`CLAUDE.md`/`INFRA.md`) | документация в том же PR. |
Без изменений API и схемы БД. Все требуемые поля уже есть в `tasks`.
## Последствия
**Плюсы:**
- Минимальный blast radius: разделяемая `send_telegram` не тронута → нулевой риск для живого
трекера и прочих уведомлений; безопасно для self-hosting.
- Детерминированная Plane-ссылка (uuid), не зависит от zero-padding identifier.
- Loopback-guard снимает противоречие AC-2/AC-3 и исключает «битые localhost-ссылки» в проде.
- Деплой штатный: не требует рестарта прод-контейнера сверх обычного деплоя; деплой ORCH
идёт через обязательный `deploy-staging` (8501).
**Минусы / ограничения:**
- Нет inline-кнопок (по дизайну отклонено) — UX чуть менее «кнопочный»; при необходимости
отдельный work item.
- Plane-ссылка появится только после задания `ORCH_PLANE_WEB_URL` на хосте (`.env`/`.env.staging`)
— см. `07-infra-requirements.md`. До этого момента graceful degradation: уведомление уходит
только с BRD-ссылкой.
- Корректность браузерного роута Plane (`/{workspace}/projects/{id}/issues/{id}/`) зависит от
версии Plane; риск зафиксирован в `10-tech-risks.md`.
## Открытые вопросы, переданные дальше
- **Значение `ORCH_PLANE_WEB_URL`** подтверждает Owner/INFRA при деплое (см. `07-infra-requirements.md`).
Это конфиг-параметр, а не блокер архитектуры.

View File

@@ -1,38 +0,0 @@
# 07-Infra Requirements — ORCH-017
Work Item: **ORCH-017** · Repo: `orchestrator`
Опирается на ADR-001 (Р-3). Меняется только env-карта; топология контейнеров/портов — без изменений.
## 1. Новая env-переменная
| Ключ | env | Дефолт | Назначение |
|------|-----|--------|------------|
| `plane_web_url` | `ORCH_PLANE_WEB_URL` | `""` (пусто) | Внешний **браузерный** базовый URL Plane для кликабельной ссылки на issue из Telegram. НЕ путать с внутренним `ORCH_PLANE_API_URL` (`http://localhost:8091`), который пригоден только для API. |
### Семантика резолва (ADR-001 Р-3)
```
plane_web_base = (ORCH_PLANE_WEB_URL or ORCH_PLANE_API_URL).rstrip("/")
```
- Если `plane_web_base` пуст **или** указывает на loopback (`localhost`, `127.0.0.1`,
`0.0.0.0`, `[::1]`) — Plane-ссылка **опускается** (graceful degradation, NFR-1). Без
заданного `ORCH_PLANE_WEB_URL` уведомление уходит только с BRD-ссылкой — это нормально.
## 2. Что требуется от Owner / INFRA
1. **Подтвердить значение `ORCH_PLANE_WEB_URL`** — внешний адрес Plane UI (тот, по которому
Слава открывает Plane в браузере). Это единственный внешний вход, требующий решения Owner.
2. Прописать ключ в `.env` (prod-хост) и `.env.staging` (staging-песочница). В git значение
НЕ коммитится — канон секретов/настроек (`.env.example` — образец без значения).
3. Браузерный роут issue, который будет собран:
`{ORCH_PLANE_WEB_URL}/{ORCH_PLANE_WORKSPACE_SLUG}/projects/{plane_project_id}/issues/{plane_issue_id}/`.
Проверить на одной задаче, что он открывается в текущей версии Plane (см. риск R-3 в
`10-tech-risks.md`).
## 3. Переиспользуемые (без изменений) настройки
- `ORCH_GITEA_PUBLIC_URL` / `ORCH_GITEA_URL`, `ORCH_GITEA_OWNER` — для BRD-ссылки.
- `ORCH_PLANE_WORKSPACE_SLUG` — workspace в Plane-URL.
## 4. Топология / деплой
- Контейнеры, порты, сети — **без изменений**. Новый ключ читается из `.env` при старте
(`pydantic Settings`, `env_prefix=ORCH_`).
- Деплой self (ORCH) — штатный, через обязательный `deploy-staging` (8501) перед прод-деплоем
(`orchestrator`, 8500). Рестарт прода сверх обычного деплоя НЕ требуется.
- Документировать ключ в env-карте: `CLAUDE.md` и/или `docs/operations/INFRA.md` (в том же PR).

View File

@@ -1,19 +0,0 @@
# 10-Tech Risks — ORCH-017
Work Item: **ORCH-017** · Repo: `orchestrator`
Опирается на ADR-001. Шкала: вероятность × влияние.
| ID | Риск | Вер. | Влияние | Митигация |
|----|------|------|---------|-----------|
| R-1 | **Self-hosting: уведомление роняет поток.** Исключение при построении ссылок (нет данных в `tasks`, неконсистентный реестр проектов) прерывает `notify_approve_requested` и тормозит конвейер всех проектов. | Низк. | Выс. | NFR-1/ADR Р-4: один SELECT в `try/except`, каждая ссылка строится независимо и опускается при нехватке данных; сообщение и призыв отправляются всегда. Тест на ветви degradation (`tests/test_notify_approve_links.py`). |
| R-2 | **Битый/непубличный Plane-URL.** Фоллбэк на `plane_api_url=localhost:8091` дал бы некликабельную ссылку снаружи хоста (нарушение AC-2). | Сред. | Сред. | ADR Р-3 loopback-guard: при пустом/loopback базовом URL Plane-ссылка опускается, а не вставляется битой. Значение `ORCH_PLANE_WEB_URL` подтверждает Owner/INFRA (`07-infra-requirements.md`). |
| R-3 | **Несовпадение браузерного роута Plane.** Формат `/{workspace}/projects/{id}/issues/{id}/` зависит от версии Plane; иной роут → ссылка ведёт в никуда (открывается, но не на ту issue). | Низк. | Сред. | Проверить роут на одной реальной задаче после задания `ORCH_PLANE_WEB_URL` (acceptance в staging). uuid `plane_issue_id` детерминирован — ошибка может быть только в шаблоне пути, не в идентификаторе. |
| R-4 | **Поломка HTML-разметки сообщения.** Неэкранированная динамическая подпись (напр. символы `<`/`&` в `work_item_id`/title) ломает `parse_mode="HTML"` → Telegram отвергает сообщение. | Низк. | Сред. | NFR-3/ADR Р-4: `html.escape` на всех подписях; URL только из доверенных конфиг/БД-значений. Тест на спецсимволы. |
| R-5 | **Регрессия «дубль-сообщения».** Случайное добавление второго `send_telegram` или повторная отправка трекера как нового сообщения. | Низк. | Низк. | ADR Р-5: инвариант «один `send_telegram`», порядок действий зафиксирован; регресс-тесты `tests/test_telegram_tracker.py`, `tests/test_notify_done_regression.py`. |
| R-6 | **Zero-padding identifier.** Короткий `browse/<IDENT>` промахнулся бы по issue (`ORCH-017` vs `ORCH-17`). | — | — | Снят на корню: ADR Р-2 использует uuid `plane_issue_id`, короткий формат отклонён. |
## Сводно
Изменение косметическое и изолированное: нет правок реестра гейтов/стадий, схемы БД, API и
разделяемой `send_telegram`. Главный класс риска — self-hosting-устойчивость (R-1) — закрыт
graceful-degradation контрактом ADR Р-4. Внешний незакрытый вход — значение `ORCH_PLANE_WEB_URL`
(R-2/R-3), проверяется в staging до прод-деплоя.

View File

@@ -1,83 +0,0 @@
---
type: review
work_item_id: ORCH-017
verdict: REQUEST_CHANGES
version: 4
---
# Review ORCH-017
## Summary
Основная фича (прямые BRD-/Plane-ссылки в `notify_approve_requested`) реализована
качественно и соответствует ТЗ, ADR-001 и всем критериям приёмки (подтверждено в
review v2: изменения по фиче — только `src/config.py` и `src/notifications.py`).
P0 из review v3 (правка разделяемого гейта `check_tests_passed` коммитом `e62d51a`,
нарушавшая ADR-001 Р-5 и ТЗ §7) **снят**: коммит `d615747` откатил изменение
`src/qg/checks.py` (вынесено в отдельный work item ORCH-47 со своим ADR). Код гейта
теперь идентичен `main` (читает только `verdict:`/`status:`); ADR-001 Р-5 и ТЗ §7
снова консистентны с кодом. ✔
Однако откат кода **не сопровождён откатом документации**: `CHANGELOG.md` и
`docs/architecture/README.md` всё ещё описывают откаченную правку гейта и ссылаются
на не существующие в этом PR тесты `tests/test_qg.py`. Это новый doc↔code конфликт
(golden source). → REQUEST_CHANGES (P1).
## Соответствие ТЗ
- §3.1§3.2, §4§6 (фича уведомления) — выполнено. `_build_brd_link` /
`_build_plane_issue_link` строят ссылки независимо, встроены в текст одного
сообщения; призыв «Переведите задачу в статус Approved …» сохранён;
`html.escape` на динамике; порядок `mark_brd_review_started → update_task_tracker
→ send_telegram(msg)` соблюдён; `Settings.plane_web_url` + фолбэк добавлены. ✔
- §7 — соблюдено. Реестр `QG_CHECKS`, стадии и машинные вердикты в коде не меняются
(правка гейта откачена в `d615747`). ✔
## Соответствие ADR
- ADR-001 (Р-1…Р-5) — соблюдён. Ссылки HTML-`<a>` в тексте, `send_telegram` не
тронута; полный Plane-URL по uuid; `ORCH_PLANE_WEB_URL` + loopback-guard
(`_is_loopback_base`); graceful degradation; «одно сообщение, без дублей». ✔
- ADR-001 Р-5 vs код — конфликт снят откатом гейта. ✔
## Качество кода
Фича `notifications.py`/`config.py` — без замечаний. Чтение полей задачи
(`_get_task_link_fields`) и обе сборки ссылок защищены try/except и никогда не
роняют alert (AC-6); loopback-guard корректно опускает неклика­бельный Plane-URL
(AC-2/AC-3); `html.escape(..., quote=True)` на href и `html.escape(work_item_id)`
на подписи (AC-7). Тесты `tests/test_notify_approve_links.py`,
`tests/test_analysis_approve_flow_links.py` присутствуют и содержательны.
## Findings
### P0 — Blocker
- (нет)
### P1 — Must fix
- [ ] **Документация описывает откаченный код (doc↔code конфликт).** После
revert-коммита `d615747` код `src/qg/checks.py` НЕ читает `result:` (только
`verdict:`/`status:`), но документация осталась от состояния `e62d51a`:
- `docs/architecture/README.md:61` утверждает, что `check_tests_passed`
читает `verdict:`/`status:`/`result:` — это ложно для текущего кода и
вводит в заблуждение по поведению разделяемого прод-гейта (self-hosting:
tester, написавший только `result: PASS`, реально провалит гейт).
- `CHANGELOG.md:24` (секция Fixed) содержит запись о правке гейта
`check_tests_passed` под тегом ORCH-017 и ссылается на отсутствующие в PR
тесты `tests/test_qg.py::TestCheckTestsPassed::test_result_pass_only_passes`
/ `…::test_result_fail_only_fails`.
**Резолюция:** убрать из ORCH-017 PR обе записи (откатить README:61 к
формулировке `main` и удалить CHANGELOG-entry про гейт) — правка гейта
принадлежит ORCH-47 и должна документироваться там вместе с её кодом.
### P2 — Should fix
- [ ] `13-test-report.md` (`result: PASS`) относится к прогону, включавшему
откаченную правку гейта; после устранения P1 канонический ре-тест — на
стадии testing (отчёт не должен ссылаться на снятые из PR изменения).
## Документация
Правило «изменён `src/` → обновлена документация в том же PR» по фиче уведомления —
выполнено: `CHANGELOG.md` (Added), `.env.example` (`ORCH_PLANE_WEB_URL`),
`docs/operations/INFRA.md` (env-карта), ADR-001. ✔
Неконсистентность (P1): документация про откаченную правку гейта `check_tests_passed`
осталась в `CHANGELOG.md` (Fixed) и `docs/architecture/README.md`, хотя
соответствующий код отозван (`d615747`) и перенесён в ORCH-47. Доку нужно привести в
соответствие с кодом этого PR.

View File

@@ -1,91 +0,0 @@
---
type: test-report
work_item_id: ORCH-017
result: PASS
---
# Test Report — ORCH-017
Прямые ссылки на BRD и Plane-таску в Telegram-уведомлении об апруве BRD.
Вердикт review (`12-review.md`): **APPROVED** ✔ — прогон регресса допущен.
## Окружение
- Python: 3.12.13
- pytest: 8.3.3 (pytest-asyncio 0.23.8, anyio 4.13.0)
- Дата: 2026-06-05
- Ветка: `feature/ORCH-017-brd-plane-telegram`
- Прод-контейнер `orchestrator` (8500) НЕ перезапускался; smoke — только read-only GET.
## Smoke test API (prod, read-only)
| Endpoint | HTTP | Результат |
|----------|------|-----------|
| `GET /health` | 200 | `{"status":"ok","service":"orchestrator"}` — PASS |
| `GET /status` | 200 | active_tasks содержит task #35 ORCH-017 (stage=testing) — PASS |
| `GET /queue` | 200 | counts running=1, failed=0, breaker=closed, preflight ok — PASS |
> `curl` в окружении отсутствует — smoke выполнен через `urllib.request` (GET, без побочных эффектов).
## Результаты по test-plan (04-test-plan.yaml)
| TC ID | Описание | Тест | Результат |
|-------|----------|------|-----------|
| TC-01 | BRD-ссылка на `01-brd.md` (Gitea branch-view) | `test_notify_approve_links::test_tc01_brd_link_present` | PASS |
| TC-02 | Plane-ссылка (web-URL+workspace+project+issue_id) | `…::test_tc02_plane_link_present` | PASS |
| TC-03 | Фоллбэки URL (gitea_public_url→gitea_url, plane_web_url→plane_api_url) | `…::test_tc03_url_fallbacks` | PASS |
| TC-04 | Сохранён призыв «Approved» | `…::test_tc04_keeps_approved_call_to_action` | PASS |
| TC-05 | Ровно одно пингующее сообщение (не silent) | `…::test_tc05_single_notifying_message` | PASS |
| TC-06 | Graceful: branch/issue=None — без исключения | `…::test_tc06_graceful_missing_branch_and_issue` | PASS |
| TC-07 | Пустой Plane-base → Plane-ссылка опущена, BRD остаётся | `…::test_tc07_plane_base_empty_drops_plane_link_keeps_brd` | PASS |
| TC-07b | Loopback Plane-base отбрасывается (доп.) | `…::test_tc07b_loopback_plane_base_dropped` | PASS |
| TC-08 | parse_mode=HTML, html.escape, валидная разметка | `…::test_tc08_html_escaped_and_valid_markup` | PASS |
| TC-08b | send_telegram сохраняет parse_mode=HTML (доп.) | `…::test_tc08b_send_telegram_keeps_parse_mode_html` | PASS |
| TC-09 | Регрессия трекера (silent edit, без дублей) | `test_telegram_tracker.py` (полный набор) | PASS |
| TC-10 | Поток analysis-approved строит ссылки из БД | `test_analysis_approve_flow_links::test_tc10_approved_flow_builds_links_from_db` | PASS |
| TC-11 | (Условный) inline-кнопки | — | N/A — вариант кнопок отклонён (ADR-001 Р-1) |
| TC-12 | (Условный) обратная совместимость send_telegram c reply_markup | — | N/A — вариант кнопок отклонён (ADR-001 Р-1) |
Все запланированные тесты (TC-01…TC-10) — PASS. Условные TC-11/TC-12 не применимы:
ADR-001 (Р-1) зафиксировал HTML-ссылки в тексте без изменения сигнатуры `send_telegram`.
## Покрытие критериев приёмки (03-acceptance-criteria.md)
| AC | Покрывающие TC | Статус |
|----|----------------|--------|
| AC-1 | TC-01, TC-10 | PASS |
| AC-2 | TC-02, TC-10 | PASS |
| AC-3 | TC-01, TC-02, TC-03 | PASS |
| AC-4 | TC-04 | PASS |
| AC-5 | TC-05, TC-09 | PASS |
| AC-6 | TC-06, TC-07, TC-07b | PASS |
| AC-7 | TC-08, TC-08b | PASS |
| AC-8 | TC-09, TC-10 | PASS |
| AC-9 | проверено review (CHANGELOG/.env.example/INFRA.md/ADR) | PASS |
| AC-10 | полный регресс `pytest tests/` | PASS |
## Вывод pytest
### Целевые тесты ORCH-017
```
tests/test_notify_approve_links.py::test_tc01_brd_link_present PASSED
tests/test_notify_approve_links.py::test_tc02_plane_link_present PASSED
tests/test_notify_approve_links.py::test_tc03_url_fallbacks PASSED
tests/test_notify_approve_links.py::test_tc04_keeps_approved_call_to_action PASSED
tests/test_notify_approve_links.py::test_tc05_single_notifying_message PASSED
tests/test_notify_approve_links.py::test_tc06_graceful_missing_branch_and_issue PASSED
tests/test_notify_approve_links.py::test_tc07_plane_base_empty_drops_plane_link_keeps_brd PASSED
tests/test_notify_approve_links.py::test_tc07b_loopback_plane_base_dropped PASSED
tests/test_notify_approve_links.py::test_tc08_html_escaped_and_valid_markup PASSED
tests/test_notify_approve_links.py::test_tc08b_send_telegram_keeps_parse_mode_html PASSED
tests/test_analysis_approve_flow_links.py::test_tc10_approved_flow_builds_links_from_db PASSED
11 passed in 0.53s
```
### Полный регресс
```
======================== 434 passed, 1 warning in 7.99s ========================
```
Единственное предупреждение — PydanticDeprecatedSince20 (`src/config.py:4`, class-based config),
предсуществующее, к ORCH-017 не относится, на результат не влияет.
## Итог
**PASS** — 434/434 теста зелёные, целевые TC-01…TC-10 пройдены, все 10 критериев приёмки
покрыты, smoke API прод-инстанса OK. Задача готова к стадии **deploy-staging**.

View File

@@ -15,82 +15,6 @@ from ..plane_sync import notify_stage_change as plane_notify_stage, add_comment
logger = logging.getLogger("orchestrator.launcher")
# ORCH-41: valid --effort values accepted by the Claude CLI. An effort that is
# not in this set is treated as misconfiguration: logged and dropped (no flag),
# never passed through to the CLI.
VALID_EFFORTS = frozenset({"low", "medium", "high", "xhigh", "max"})
def _resolve_agent_attr(agent, project_id, project_map_attr, env_attr_prefix,
default_attr):
"""ORCH-41 shared resolver with priority:
1. ProjectConfig.<project_map_attr>[agent] (per-project override)
2. settings.<env_attr_prefix><agent> (per-agent env, if non-empty)
3. settings.<default_attr> (global default)
4. "" (no flag -> CLI default)
project_id is the Plane project uuid. It is resolved to a ProjectConfig via
the registry; an unknown / empty id simply skips level 1. A missing per-agent
settings attribute (e.g. unknown agent name) skips level 2.
"""
# Level 1: per-project override.
if project_id:
from ..projects import get_project_by_plane_id
proj = get_project_by_plane_id(project_id)
if proj is not None:
override = getattr(proj, project_map_attr, {}).get(agent)
if override:
return override
# Level 2: per-agent env (settings.<prefix><agent>), if defined & non-empty.
per_agent = getattr(settings, f"{env_attr_prefix}{agent}", "")
if per_agent:
return per_agent
# Level 3: global default.
default = getattr(settings, default_attr, "")
if default:
return default
# Level 4: nothing -> CLI default.
return ""
def resolve_agent_model(agent: str, project_id: str = None) -> str:
"""ORCH-41: resolve the LLM model for an agent (optionally per-project).
Returns "" when no model is configured at any level -> caller omits --model
and the CLI default applies. See _resolve_agent_attr for the priority order.
"""
return _resolve_agent_attr(
agent, project_id,
project_map_attr="agent_models",
env_attr_prefix="agent_model_",
default_attr="agent_model_default",
)
def resolve_agent_effort(agent: str, project_id: str = None) -> str:
"""ORCH-41: resolve the --effort level for an agent (optionally per-project).
Same priority as resolve_agent_model. The resolved value is validated against
VALID_EFFORTS; an invalid value is logged and dropped (returns "") so a typo
in env/projects_json can never pass a bad flag to the CLI.
"""
value = _resolve_agent_attr(
agent, project_id,
project_map_attr="agent_efforts",
env_attr_prefix="agent_effort_",
default_attr="agent_effort_default",
)
if value and value not in VALID_EFFORTS:
logger.warning(
f"Invalid effort '{value}' for agent '{agent}' "
f"(allowed: {sorted(VALID_EFFORTS)}); omitting --effort"
)
return ""
return value
def prune_run_logs(runs_dir, keep_days=30, keep_max=500, active_paths=None):
"""L-2: best-effort rotation of per-run logs (<runs_dir>/*.log).
@@ -161,6 +85,7 @@ class AgentLauncher:
"system_prompt": ".openclaw/agents/architect.md",
"task_file": ".task-arch.md",
"allowed_tools": "Read,Write,Edit,Bash",
"model": "opus",
},
"developer": {
"system_prompt": ".openclaw/agents/developer.md",
@@ -171,6 +96,7 @@ class AgentLauncher:
"system_prompt": ".openclaw/agents/reviewer.md",
"task_file": ".task-review.md",
"allowed_tools": "Read,Write,Edit,Bash",
"model": "opus",
},
"tester": {
"system_prompt": ".openclaw/agents/tester.md",
@@ -245,12 +171,6 @@ class AgentLauncher:
_br_row = get_db().execute("SELECT branch FROM tasks WHERE id=?", (task_id,)).fetchone() if task_id else None
agent_branch = _br_row[0] if _br_row else "main"
# ORCH-41: resolve the Plane project uuid for this repo so per-project
# model/effort overrides apply. Unknown repo -> None (env/default only).
from ..projects import get_project_by_repo
_proj = get_project_by_repo(repo)
project_id = _proj.plane_project_id if _proj else None
# Ensure the per-branch worktree exists and is on the right branch.
work_path = ensure_worktree(repo, agent_branch)
@@ -284,14 +204,8 @@ class AgentLauncher:
system_prompt = config["system_prompt"]
allowed_tools = config["allowed_tools"]
# ORCH-41: model + effort + optional fallback are resolved from config
# (project-override > per-agent env > default), not hardcoded in AGENT_CONFIGS.
model = resolve_agent_model(agent, project_id)
effort = resolve_agent_effort(agent, project_id)
model = config.get("model", "")
model_flag = f"--model {model} " if model else ""
effort_flag = f"--effort {effort} " if effort else ""
fb = settings.agent_fallback_model
fb_flag = f"--fallback-model {fb} " if fb else ""
# No git fetch/checkout here: ensure_worktree() already put the worktree on
# the right branch. The agent simply runs inside its isolated work_path.
@@ -304,7 +218,7 @@ class AgentLauncher:
f'cd {work_path} && '
f'{self.CLAUDE_BIN} --print '
f'--output-format json '
f'{model_flag}{effort_flag}{fb_flag}'
f'{model_flag}'
f'"$(cat {task_file})" '
f'--system-prompt "$(cat {system_prompt})" '
f'--allowedTools {allowed_tools}'
@@ -593,15 +507,11 @@ class AgentLauncher:
from ..notifications import send_telegram
send_telegram(f"\u26a0\ufe0f {_wid}: Agent {agent} failed (exit_code={exit_code}). Check logs: /app/data/runs/{run_id}.log")
# Feature 4 + ORCH-016: post the unified per-agent status comment under
# that agent's bot, threading the wall-clock duration we just measured
# straight through (ADR-001 §6: explicit param wins over DB fallback).
# The deployer finishing the task also posts the per-task usage summary.
# Feature 4: post the per-agent usage comment under that agent's bot, and
# — for the deployer finishing the task — the per-task usage summary.
if exit_code == 0:
try:
self._post_usage_comments(
run_id, agent, repo, branch, _usage, duration_s=_duration_s
)
self._post_usage_comments(run_id, agent, repo, branch, _usage)
except Exception as e:
logger.warning(f"run_id={run_id}: usage comment failed: {e}")
@@ -769,67 +679,42 @@ class AgentLauncher:
logger.error(f"Auto-advance failed for run_id={run_id}: {e}")
def _post_usage_comments(self, run_id, agent, repo, branch, usage, duration_s=None):
"""Feature 4 + ORCH-016: post the unified per-agent status comment.
def _post_usage_comments(self, run_id, agent, repo, branch, usage):
"""Feature 4: post the per-agent usage comment (and Deployer summary).
- Always (on success, with a work_item_id): a per-agent finish comment
via ``usage.build_status_comment(...)``, authored by the finishing
agent's Plane bot. The comment carries:
* single-line header (icon + role + per-stage description),
* machine verdict line for reviewer / tester / deployer (when the
relevant frontmatter is present in the worktree),
* the agent's wall-clock duration (``duration_s`` is the measured
value in _monitor_agent; DB fallback is unused on this path),
* an HTML <ul> of artifact links scoped per agent,
* a ``<sub>`` token/cost tail.
with token/cost, authored by the finishing agent's Plane bot.
- When the deployer finishes: also a per-task summary (SUM over
agent_runs GROUP BY agent), authored by the deployer.
The deployer's `stage=` is resolved from the task row so the helper can
pick between 14-deploy-log.md (prod) and 15-staging-log.md (staging).
"""
from ..usage import build_status_comment, task_summary_comment
from ..git_worktree import get_worktree_path
from ..usage import usage_comment, task_summary_comment
conn = get_db()
row = conn.execute(
"SELECT id, work_item_id, stage FROM tasks WHERE repo=? AND branch=?",
"SELECT id, work_item_id FROM tasks WHERE repo=? AND branch=?",
(repo, branch),
).fetchone()
conn.close()
if not row:
return
task_id, work_item_id, stage = row[0], row[1], row[2]
task_id, work_item_id = row[0], row[1]
if not work_item_id:
return
# Observability: every agent's finish comment links its artifact(s)
# (reviewer->12-review, tester->13-test-report, deployer->14- or 15-,
# (reviewer->12-review, tester->13-test-report, deployer->14-deploy-log,
# architect->ADR, developer->PR/branch). For the developer we resolve the
# open PR number so the link points straight at it.
pr_number = None
if agent == "developer":
pr_number = self._open_pr_number(repo, branch)
# Best-effort worktree path — drives AC-8 (skip missing artifacts) and
# the verdict frontmatter read. Falls back to None on lookup error so
# the comment still goes out without the verdict line / file probe.
try:
worktree_root = get_worktree_path(repo, branch)
except Exception:
worktree_root = None
plane_add_comment(
work_item_id,
build_status_comment(
usage_comment(
agent,
usage,
repo=repo,
branch=branch,
work_item_id=work_item_id,
pr_number=pr_number,
stage=stage,
usage=usage,
duration_s=duration_s,
task_id=task_id,
worktree_root=worktree_root,
),
author=agent,
)

View File

@@ -4,11 +4,6 @@ from pydantic_settings import BaseSettings
class Settings(BaseSettings):
# Plane
plane_api_url: str = "http://localhost:8091"
# ORCH-017: external (browser) web URL of Plane for clickable issue links in
# notifications, e.g. https://plane.example.org. Falls back to plane_api_url,
# but a loopback fallback (localhost/127.0.0.1) is treated as "no web URL" and
# the Plane link is omitted (see notifications._build_plane_issue_link).
plane_web_url: str = ""
plane_api_token: str = ""
plane_workspace_slug: str = ""
plane_webhook_secret: str = ""
@@ -83,34 +78,6 @@ class Settings(BaseSettings):
agent_kill_grace_seconds: int = 20
agent_timeout_overrides_json: str = ""
# ORCH-41: per-agent LLM model. Empty -> agent_model_default. Resolution order:
# project-override (projects_json agent_models) > ORCH_AGENT_MODEL_<AGENT> >
# agent_model_default > CLI default (no --model flag). Default is 4-8 because
# 4-7 == 4-8 in price (Slava 05.06); do NOT hardcode the version anywhere else.
agent_model_default: str = "claude-opus-4-8"
agent_model_analyst: str = ""
agent_model_architect: str = ""
agent_model_developer: str = ""
agent_model_reviewer: str = ""
agent_model_tester: str = ""
agent_model_deployer: str = ""
# ORCH-41: per-agent effort / reasoning level: low|medium|high|xhigh|max.
# Empty -> agent_effort_default. Same resolution order as model. Default split:
# thinking agents (analyst/architect/developer/reviewer) -> high; mechanical
# agents (tester/deployer) -> medium.
agent_effort_default: str = "high"
agent_effort_analyst: str = "high"
agent_effort_architect: str = "high"
agent_effort_developer: str = "high"
agent_effort_reviewer: str = "high"
agent_effort_tester: str = "medium"
agent_effort_deployer: str = "medium"
# ORCH-41: optional per-agent fallback model used when the primary is
# overloaded (--fallback-model, works with --print). Empty -> no flag.
agent_fallback_model: str = ""
# L-2: run-log rotation. Old per-run logs in <data>/runs/*.log are pruned at
# app startup (best-effort). A *.log is removed if it is older than
# log_keep_days OR not within the log_keep_max most-recent logs (whichever

View File

@@ -1,75 +0,0 @@
"""Safe single-key YAML frontmatter reader (ORCH-016 / ADR-001 §5).
The status-comment builder (build_status_comment) needs to surface verdict /
deploy_status / staging_status from the per-stage artifact files (12-review.md,
13-test-report.md, 14-deploy-log.md, 15-staging-log.md). Those files share the
same leading-YAML-frontmatter convention used by the quality gates — but the
comment hot-path must NEVER raise: a missing file, malformed YAML, or absent
key should simply suppress the verdict line, not break the run.
This module is a tiny defensive helper:
- `read_frontmatter_value(path, key)` -> str | None
- swallows every exception, logs to logger.debug, returns None.
It intentionally duplicates ~10 lines of YAML-frontmatter logic that already
exist in `src/qg/checks.py` (S-5 / БАГ 8 / ET-013 fixes). ADR-001 §5 accepts
this duplication to keep the blast radius of ORCH-016 small (no QG refactor in
this PR); merging into a single parser is a follow-up task.
"""
import logging
logger = logging.getLogger("orchestrator.frontmatter")
def read_frontmatter_value(path: str, key: str) -> str | None:
"""Return the value of `key` from the leading YAML frontmatter of `path`.
Format expected (canonical, matching qg/checks.py):
---
key: value
other: ...
---
<body>
Never raises. Returns None for any of:
- missing/unreadable file,
- no leading `---` frontmatter,
- malformed/unterminated frontmatter,
- YAML parse error,
- frontmatter is not a mapping,
- key absent (or its value is None/empty).
The returned value is stringified and stripped (whitespace removed); casing
is preserved so the caller decides whether to upper/lower for matching.
"""
try:
with open(path, "r", encoding="utf-8", errors="replace") as f:
content = f.read()
except OSError as e:
logger.debug(f"read_frontmatter_value: cannot open {path}: {e}")
return None
if not content.startswith("---"):
return None
parts = content.split("---", 2)
if len(parts) < 3:
# Unterminated frontmatter.
return None
try:
import yaml
fm = yaml.safe_load(parts[1]) or {}
except Exception as e: # yaml.YAMLError + anything pyyaml may surface
logger.debug(f"read_frontmatter_value: yaml parse failed for {path}: {e}")
return None
if not isinstance(fm, dict):
return None
raw = fm.get(key)
if raw is None:
return None
value = str(raw).strip()
return value or None

View File

@@ -544,105 +544,6 @@ def notify_qg_failure(task_id: int, stage: str, check: str, reason: str):
logger.warning(f"\u26a0\ufe0f {work_item_id}: QG {check} \u2014 failed: {reason}")
# ORCH-017: hosts that are not clickable off the deploy box. A Plane web-base
# resolving to one of these (the plane_api_url loopback default) means "no usable
# browser URL" -> the Plane link is omitted rather than emitted broken (ADR-001 Р-3).
_LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "0.0.0.0", "::1"})
def _is_loopback_base(url: str) -> bool:
"""True if the URL's host is a loopback/local address (not clickable off-host).
Empty/garbage URLs count as loopback (i.e. unusable) so callers omit the link.
"""
if not url:
return True
try:
from urllib.parse import urlparse
host = (urlparse(url).hostname or "").lower()
return (not host) or host in _LOOPBACK_HOSTS
except Exception:
return True
def _get_task_link_fields(task_id: int):
"""ORCH-017: read (repo, branch, plane_issue_id) for a task. Never raises.
Returns (None, None, None) on any error / missing row so link building can
degrade gracefully (AC-6).
"""
try:
from .db import get_db
conn = get_db()
row = conn.execute(
"SELECT repo, branch, plane_issue_id FROM tasks WHERE id=?", (task_id,)
).fetchone()
conn.close()
if not row:
return None, None, None
return row["repo"], row["branch"], row["plane_issue_id"]
except Exception as e:
logger.warning(f"_get_task_link_fields({task_id}) failed: {e}")
return None, None, None
def _build_brd_link(repo, branch, work_item_id) -> str | None:
"""ORCH-017: '<a>' to 01-brd.md in Gitea branch-view, or None if data missing.
Mirrors the canonical branch-view pattern in src/usage.py: base =
gitea_public_url or gitea_url, owner = gitea_owner (AC-1/AC-3). The href is
html.escaped as defence-in-depth even though parts come from trusted
config/DB (AC-7).
"""
s = _get_settings()
base = (
getattr(s, "gitea_public_url", "") or getattr(s, "gitea_url", "")
).rstrip("/")
owner = getattr(s, "gitea_owner", "")
if not (base and owner and repo and branch and work_item_id):
return None
url = (
f"{base}/{owner}/{repo}/src/branch/{branch}"
f"/docs/work-items/{work_item_id}/01-brd.md"
)
return (
f'<a href="{html.escape(url, quote=True)}">'
f"\U0001f4c4 Открыть BRD</a>"
)
def _build_plane_issue_link(repo, plane_issue_id) -> str | None:
"""ORCH-017: '<a>' to the Plane issue browser page, or None if unusable.
Full path per ADR-001 Р-2:
``{web_base}/{workspace_slug}/projects/{project_id}/issues/{issue_id}/``.
web_base = plane_web_url or plane_api_url (AC-3); a loopback base is treated
as "no web URL" and the link is omitted (loopback-guard, AC-2/AC-6).
"""
s = _get_settings()
web_base = (
getattr(s, "plane_web_url", "") or getattr(s, "plane_api_url", "")
).rstrip("/")
workspace = getattr(s, "plane_workspace_slug", "")
if not (web_base and workspace and plane_issue_id) or _is_loopback_base(web_base):
return None
try:
from .projects import get_project_by_repo
project = get_project_by_repo(repo) if repo else None
except Exception:
project = None
if not project or not getattr(project, "plane_project_id", ""):
return None
url = (
f"{web_base}/{workspace}/projects/{project.plane_project_id}"
f"/issues/{plane_issue_id}/"
)
return (
f'<a href="{html.escape(url, quote=True)}">'
f"✅ Задача в Plane</a>"
)
def notify_approve_requested(task_id: int):
"""ALERT (separate, notifying): BRD/TZ/AC ready -> flip Plane to Approved.
@@ -656,27 +557,10 @@ def notify_approve_requested(task_id: int):
except Exception as e:
logger.warning(f"notify_approve_requested: brd clock start failed: {e}")
msg = (
f"\U0001f4cb {html.escape(work_item_id)}: BRD/\u0422\u0417/AC \u0433\u043e\u0442\u043e\u0432\u044b. "
f"\U0001f4cb {work_item_id}: BRD/\u0422\u0417/AC \u0433\u043e\u0442\u043e\u0432\u044b. "
f"\u041f\u0435\u0440\u0435\u0432\u0435\u0434\u0438\u0442\u0435 \u0437\u0430\u0434\u0430\u0447\u0443 \u0432 \u0441\u0442\u0430\u0442\u0443\u0441 Approved "
f"\u0432 Plane \u0434\u043b\u044f \u043f\u0440\u043e\u0434\u043e\u043b\u0436\u0435\u043d\u0438\u044f."
)
# ORCH-017: embed direct links to the BRD doc (Gitea) and the Plane issue so
# the reviewer can open both straight from the ping. Each link is built
# independently and omitted if its data is missing; building is defensive so
# it can NEVER break the alert (AC-1/AC-2/AC-6). Still exactly one notifying
# message (AC-5); the call to action above is always preserved (AC-4).
try:
repo, branch, plane_issue_id = _get_task_link_fields(task_id)
links = [
link for link in (
_build_brd_link(repo, branch, work_item_id),
_build_plane_issue_link(repo, plane_issue_id),
) if link
]
if links:
msg = msg + "\n\n" + "\n".join(links)
except Exception as e:
logger.warning(f"notify_approve_requested({task_id}): link build failed: {e}")
logger.info(msg)
update_task_tracker(task_id)
send_telegram(msg) # separate, notifying

View File

@@ -17,7 +17,7 @@ registry is used so the system works out of the box.
import json
import logging
from dataclasses import dataclass, field
from dataclasses import dataclass
from .config import settings
@@ -30,11 +30,6 @@ class ProjectConfig:
repo: str # gitea repo name (== folder under /repos)
work_item_prefix: str # ET / ORCH
name: str # human-readable label
# ORCH-41: optional per-project agent->model / agent->effort overrides parsed
# from projects_json. frozen dataclass + mutable default -> field(default_factory=dict)
# (a bare {} default raises ValueError). Empty dict = no override (old records work).
agent_models: dict = field(default_factory=dict)
agent_efforts: dict = field(default_factory=dict)
# Built-in default registry (used when ORCH_PROJECTS_JSON is empty/invalid).
@@ -55,23 +50,6 @@ _DEFAULT_PROJECTS = [
]
def _coerce_str_map(value, idx, field_name) -> dict:
"""ORCH-41: coerce an optional projects_json sub-object into a {str: str} dict.
Missing / null -> {} (no override). A non-object value is logged and dropped so
one malformed entry can never brick the whole registry; non-string keys/values
are stringified for safety.
"""
if value is None:
return {}
if not isinstance(value, dict):
logger.error(
f"ORCH_PROJECTS_JSON[{idx}].{field_name} is not an object, ignoring"
)
return {}
return {str(k): str(v) for k, v in value.items()}
def _parse_projects_json(raw: str) -> list[ProjectConfig] | None:
"""Parse ORCH_PROJECTS_JSON. Returns None if empty/invalid (-> use default)."""
if not raw or not raw.strip():
@@ -97,8 +75,6 @@ def _parse_projects_json(raw: str) -> list[ProjectConfig] | None:
repo=str(item["repo"]),
work_item_prefix=str(item["work_item_prefix"]),
name=str(item.get("name", item["repo"])),
agent_models=_coerce_str_map(item.get("agent_models"), i, "agent_models"),
agent_efforts=_coerce_str_map(item.get("agent_efforts"), i, "agent_efforts"),
)
)
except KeyError as e:

View File

@@ -295,42 +295,57 @@ def advance_stage(
return result
def _build_analyst_ready_comment(
repo: str, work_item_id: str, branch: str, task_id: int | None = None
) -> str:
"""ORCH-016: analyst "artifacts ready" comment via the unified status helper.
def _build_analyst_ready_comment(repo: str, work_item_id: str, branch: str) -> str:
"""BUG C: HTML comment posted when analyst artifacts are ready.
Historically this function hand-built the HTML for the analyst's BUG-C
status-only verdict comment (PR #12 / #13). After ORCH-016 / ADR-001 \u00a71 every
agent goes through the single ``usage.build_status_comment(...)`` hot path,
so this is now a thin compatibility wrapper that:
Status-only model (PR #12): approval is the **Approved** status, NOT a
``:approved:`` comment and NOT moving back to In Progress. The comment asks
the stakeholder to flip the status and links the documents the analyst
actually produced.
- keeps the same 3-positional signature that ``_handle_analysis_approved_flow``
and the regression tests (``tests/test_analyst_comment.py``) already call,
- adds an optional ``task_id`` so the duration line for the analyst can be
resolved via the DB fallback (AC-14: analyst's ``_duration_s`` isn't in
scope of stage_engine, hence the fallback),
- locates the worktree so AC-8 graceful skipping of missing analyst
artifacts and ``gitea_public_url`` clickability work exactly as before.
All historical text contracts are preserved by the analyst branch inside
``build_status_comment``: \u00abApproved\u00bb, \u00abRejected\u00bb, no \u00ab:approved:\u00bb, no
\u00abIn Progress\u00bb \u2014 the existing test_analyst_comment.py assertions still hold.
Links point at the Gitea web view:
{gitea_url}/{owner}/{repo}/src/branch/{branch}/docs/work-items/{wid}/<file>
Only files that REALLY exist in the worktree are listed (no invented docs).
"""
from .usage import build_status_comment
try:
worktree_root = get_worktree_path(repo, branch)
except Exception:
worktree_root = None
return build_status_comment(
"analyst",
repo=repo,
branch=branch,
work_item_id=work_item_id,
task_id=task_id,
worktree_root=worktree_root,
text = (
"\u2705 BRD/\u0422\u0417/AC \u0433\u043e\u0442\u043e\u0432\u044b. "
"\u0414\u043b\u044f \u043f\u0440\u043e\u0434\u0432\u0438\u0436\u0435\u043d\u0438\u044f "
"\u043f\u0435\u0440\u0435\u0432\u0435\u0434\u0438\u0442\u0435 \u0437\u0430\u0434\u0430\u0447\u0443 "
"\u0432 \u0441\u0442\u0430\u0442\u0443\u0441 Approved. "
"\u0414\u043b\u044f \u043e\u0442\u043a\u043b\u043e\u043d\u0435\u043d\u0438\u044f \u2014 "
"\u043d\u0430\u043f\u0438\u0448\u0438\u0442\u0435 \u043f\u0440\u0438\u0447\u0438\u043d\u0443 "
"\u043a\u043e\u043c\u043c\u0435\u043d\u0442\u043e\u043c \u0438 \u043f\u0435\u0440\u0435\u0432\u0435\u0434\u0438\u0442\u0435 "
"\u0432 Rejected."
)
# Candidate analyst artifacts (label -> filename). Only existing ones linked.
candidates = [
("Business request", "00-business-request.md"),
("BRD", "01-brd.md"),
("\u0422\u0417 (TRZ)", "02-trz.md"),
("Acceptance Criteria", "03-acceptance-criteria.md"),
("Test Plan", "04-test-plan.yaml"),
("UI Test Cases", "04b-ui-test-cases.md"),
]
rel_dir = f"docs/work-items/{work_item_id}"
try:
wt_dir = os.path.join(get_worktree_path(repo, branch), rel_dir)
except Exception:
wt_dir = None
owner = getattr(settings, "gitea_owner", "admin")
base = (getattr(settings, "gitea_public_url", "") or settings.gitea_url).rstrip("/")
links = []
for label, fname in candidates:
if wt_dir and not os.path.isfile(os.path.join(wt_dir, fname)):
continue
href = f"{base}/{owner}/{repo}/src/branch/{branch}/{rel_dir}/{fname}"
links.append(f'<li><a href="{href}">{label}</a></li>')
if links:
text += "<br><b>\u0414\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u044b:</b><ul>" + "".join(links) + "</ul>"
return text
def _handle_analysis_approved_flow(
task_id, current_stage, repo, work_item_id, branch, agent, result: AdvanceResult
@@ -358,9 +373,7 @@ def _handle_analysis_approved_flow(
set_issue_in_review(work_item_id)
plane_add_comment(
work_item_id,
# task_id is threaded through so build_status_comment can resolve the
# analyst duration via agent_runs (ORCH-016 AC-14 DB fallback).
_build_analyst_ready_comment(repo, work_item_id, branch, task_id=task_id),
_build_analyst_ready_comment(repo, work_item_id, branch),
author="analyst",
)
notify_approve_requested(task_id)

View File

@@ -1,4 +1,4 @@
"""Feature 4 + ORCH-016: token / cost accounting and unified status comments.
"""Feature 4: token / cost accounting for agent runs.
claude --output-format json emits a single result JSON object at the end of the
run log with fields:
@@ -8,16 +8,11 @@ run log with fields:
modelUsage, num_turns, duration_ms
This module parses that JSON out of a (text-or-json) run log, records the usage
on the agent_runs row, and builds:
- per-agent status comments via build_status_comment(...) — the ORCH-016
unified format replacing the legacy usage_comment(...) and the analyst-
only stage_engine._build_analyst_ready_comment(...). Every agent now flows
through the same hot path.
- per-task summary the Deployer posts on deploy/done.
on the agent_runs row, formats a Plane comment for the finishing agent, and
builds the per-task summary the Deployer posts on deploy/done.
Everything here is defensive: a missing/garbled JSON never raises we record
NULL/0 and log a warning so a broken agent run can't crash the monitor. The
status-comment hot path likewise NEVER raises (self-hosting risk R-1).
Everything here is defensive: a missing/garbled JSON never raises \u2014 we record
NULL/0 and log a warning so a broken agent run can't crash the monitor.
"""
import json
@@ -252,88 +247,6 @@ def fmt_cost(c) -> str:
return f"${c:.2f}"
def fmt_duration(seconds) -> str:
"""Format an integer second count for the agent-finish status comment (ORCH-016).
Contract (ADR-001 §8 / AC-13):
0..59 -> '{s}s' (e.g. '0s', '12s', '59s')
60..3599 -> '{m}m {ss:02d}s' (e.g. '1m 00s', '4m 12s', '59m 59s')
>= 3600 -> '{h}h {mm:02d}m' (seconds dropped; e.g. '1h 00m', '2h 47m')
None / non-int / negative -> '' so the caller drops the 'Длительность:' line.
Pure function: no I/O, no DB.
"""
try:
if seconds is None:
return ""
s = int(seconds)
except (TypeError, ValueError):
return ""
if s < 0:
return ""
if s < 60:
return f"{s}s"
if s < 3600:
m, ss = divmod(s, 60)
return f"{m}m {ss:02d}s"
h, rem = divmod(s, 3600)
mm = rem // 60
return f"{h}h {mm:02d}m"
def get_agent_duration(task_id, agent: str) -> int | None:
"""Last finished agent_runs duration (seconds) for (task_id, agent) — DB fallback.
ORCH-016 / ADR-001 §6: used by build_status_comment when the caller does NOT
pass an explicit duration_s (chiefly the analyst path, which builds its
comment from stage_engine where _duration_s is not in scope).
Reads the last finished row for (task_id, agent) via:
SELECT CAST((julianday(finished_at) - julianday(started_at)) * 86400 AS INTEGER)
FROM agent_runs WHERE task_id=? AND agent=?
AND finished_at IS NOT NULL
ORDER BY id DESC LIMIT 1
Returns None on any of:
- missing task_id / agent,
- no matching row (or finished_at IS NULL),
- computed value < 0 (clock skew),
- DB error (logged at debug, never re-raised). This is the hot comment
path — a locked / stale DB must never crash a finishing agent.
"""
if not task_id or not agent:
return None
try:
conn = get_db()
except Exception as e:
logger.debug(f"get_agent_duration: cannot open DB for ({task_id},{agent}): {e}")
return None
try:
row = conn.execute(
"SELECT CAST((julianday(finished_at) - julianday(started_at)) * 86400 AS INTEGER) "
"FROM agent_runs WHERE task_id=? AND agent=? AND finished_at IS NOT NULL "
"ORDER BY id DESC LIMIT 1",
(task_id, agent),
).fetchone()
except Exception as e:
logger.debug(f"get_agent_duration: query failed for ({task_id},{agent}): {e}")
return None
finally:
try:
conn.close()
except Exception:
pass
if not row or row[0] is None:
return None
try:
secs = int(row[0])
except (TypeError, ValueError):
return None
if secs < 0:
return None
return secs
# Pretty agent names for comments (mirrors STAGE_AUTHORS roles).
AGENT_DISPLAY = {
"analyst": "Analyst",
@@ -385,28 +298,30 @@ def usage_comment(
work_item_id: str | None = None,
pr_number=None,
) -> str:
"""DEPRECATED (ORCH-016 / ADR-001 §1): thin wrapper around build_status_comment.
"""Build the per-agent finish comment, e.g.
'\U0001f4bb Developer \u0433\u043e\u0442\u043e\u0432 \u00b7 8.5M in (8.4M cached) / 45.8k out \u00b7 $7.29'.
The historical one-line "{icon} Role готов · 8.5M in / 45.8k out · $7.29 + links"
format has been replaced by the unified status-comment format. This wrapper
is kept only so that legacy callers (notably the test suite in
``tests/test_usage.py``) keep working; new code MUST call
``build_status_comment(...)`` directly. There is no ``duration_s`` parameter
here because the old signature did not carry it.
When repo/branch/work_item_id are supplied, the agent's artifact link(s) are
appended (BUG: only analyst used to link its docs). Missing artifacts are
silently skipped — link building never raises.
"""
return build_status_comment(
agent,
repo=repo,
branch=branch,
work_item_id=work_item_id,
pr_number=pr_number,
usage=usage,
usage = usage or {}
name = AGENT_DISPLAY.get(agent, agent.capitalize())
icon = AGENT_ICON.get(agent, "\u2705")
line = (
f"{icon} {name} \u0433\u043e\u0442\u043e\u0432 \u00b7 "
f"{fmt_in(usage)} / "
f"{fmt_tokens(usage.get('output_tokens'))} out \u00b7 "
f"{fmt_cost(usage.get('cost_usd'))}"
)
links = artifact_links(agent, repo, branch, work_item_id, pr_number)
if links:
line += "\n" + "\n".join(links)
return line
# Per-agent artifact file under docs/work-items/{wid}/ (architect/developer/
# deployer use special handling for ADR dirs, PR links, or staging logs —
# see artifact_links()).
# Per-agent artifact file under docs/work-items/{wid}/ (architect/developer use
# special handling for ADR dirs / PR links, see artifact_links()).
AGENT_ARTIFACT = {
"reviewer": ("Review", "12-review.md"),
"tester": ("Test report", "13-test-report.md"),
@@ -420,35 +335,13 @@ def artifact_links(
branch: str | None,
work_item_id: str | None,
pr_number=None,
*,
stage: str | None = None,
worktree_root: str | None = None,
) -> list[str]:
"""HTML <li><a>...</a></li> link fragments for the finishing agent's artifacts.
"""Markdown link(s) to the finishing agent's artifact(s) in Gitea.
ORCH-016 (ADR-001 §7) breaking change: this function now emits HTML anchor
fragments to feed straight into the <ul> of build_status_comment(), instead
of the legacy markdown ``[label](url)`` strings. The base URL still prefers
settings.gitea_public_url (falls back to gitea_url) so links remain clickable
from outside the deploy host, exactly like the analyst doc list.
Returned strings are individual ``<li><a href="...">label</a></li>`` items;
the caller wraps them in ``<ul>...</ul>``. Empty list (never raises) when
there is nothing to link or context is missing.
AC-8 graceful behaviour: when ``worktree_root`` is provided, a candidate
whose underlying file does NOT exist in the worktree is dropped silently.
With no worktree (unit-test / minimal context), every applicable link is
emitted without a file-existence probe (matches the legacy artifact_links
semantics; that's what existing tests in tests/test_usage.py exercise).
Per agent (ADR-001 §7, ТЗ §2.4):
developer -> Branch + (open) PR
architect -> ADR directory
reviewer -> 12-review.md
tester -> 13-test-report.md
deployer -> 14-deploy-log.md (deploy) or 15-staging-log.md (deploy-staging)
analyst -> NOT handled here; build_status_comment owns its richer list.
Uses gitea_public_url (falls back to gitea_url) for clickable links, mirroring
the analyst doc links. Returns [] (never raises) when there is nothing to
link or the required context is missing. analyst is intentionally NOT handled
here — its richer doc list lives in stage_engine._build_analyst_ready_comment.
"""
try:
from .config import settings
@@ -458,76 +351,37 @@ def artifact_links(
).rstrip("/")
if not base or not repo:
return []
items: list[str] = []
rel_dir = f"docs/work-items/{work_item_id}" if work_item_id else None
def _file_exists(rel_path: str) -> bool:
if not worktree_root:
return True
try:
import os as _os
return _os.path.isfile(_os.path.join(worktree_root, rel_path))
except Exception:
return True
def _dir_exists(rel_path: str) -> bool:
if not worktree_root:
return True
try:
import os as _os
return _os.path.isdir(_os.path.join(worktree_root, rel_path))
except Exception:
return True
links: list[str] = []
if agent == "developer":
if branch:
items.append(
f'<li><a href="{base}/{owner}/{repo}/src/branch/{branch}">'
f"Branch {branch}</a></li>"
links.append(
f"\U0001f4c2 [Branch {branch}]({base}/{owner}/{repo}/src/branch/{branch})"
)
if pr_number:
items.append(
f'<li><a href="{base}/{owner}/{repo}/pulls/{pr_number}">'
f"PR #{pr_number}</a></li>"
links.append(
f"\U0001f517 [PR #{pr_number}]({base}/{owner}/{repo}/pulls/{pr_number})"
)
return items
return links
if agent == "architect":
if branch and rel_dir:
adr_rel = f"{rel_dir}/06-adr"
if _dir_exists(adr_rel):
items.append(
f'<li><a href="{base}/{owner}/{repo}/src/branch/{branch}/'
f'{adr_rel}">ADR</a></li>'
)
return items
if agent == "deployer":
# Stage-aware (ORCH-35 + ORCH-016 §2.4): 'deploy-staging' picks the
# staging log; 'deploy' (or unknown) picks the deploy log. Other
# deployer artifacts (smoke output etc.) are out of scope.
if branch and rel_dir:
if (stage or "").strip() == "deploy-staging":
fname, label = "15-staging-log.md", "Staging log"
else:
fname, label = "14-deploy-log.md", "Deploy log"
if _file_exists(f"{rel_dir}/{fname}"):
items.append(
f'<li><a href="{base}/{owner}/{repo}/src/branch/{branch}/'
f'{rel_dir}/{fname}">{label}</a></li>'
)
return items
if branch and work_item_id:
adr_dir = (
f"{base}/{owner}/{repo}/src/branch/{branch}/"
f"docs/work-items/{work_item_id}/06-adr"
)
links.append(f"\U0001f4d0 [ADR]({adr_dir})")
return links
spec = AGENT_ARTIFACT.get(agent)
if spec and branch and rel_dir:
if spec and branch and work_item_id:
label, fname = spec
if _file_exists(f"{rel_dir}/{fname}"):
items.append(
f'<li><a href="{base}/{owner}/{repo}/src/branch/{branch}/'
f'{rel_dir}/{fname}">{label}</a></li>'
)
return items
href = (
f"{base}/{owner}/{repo}/src/branch/{branch}/"
f"docs/work-items/{work_item_id}/{fname}"
)
links.append(f"\U0001f4c4 [{label}]({href})")
return links
except Exception:
return []
@@ -542,295 +396,6 @@ AGENT_ICON = {
}
# ---------------------------------------------------------------------------
# ORCH-016: unified status comment for every agent (analyst..deployer)
# ---------------------------------------------------------------------------
# Per-agent one-line description used in the status comment header (ADR-001 §2).
# Trailing periods are kept to match the literal assertions in AC-1..AC-5.
_AGENT_DESCRIPTIONS = {
"analyst": (
"Подготовил BRD / "
"ТЗ / Acceptance Criteria. "
"Для продвижения "
"переведите задачу "
"в статус Approved. "
"Для отклонения — "
"напишите причину "
"комментом и "
"переведите в Rejected."
),
"architect": (
"Завершил "
"архитектурную "
"проработку. "
"См. ADR ниже."
),
"developer": (
"Завершил "
"разработку. "
"См. PR / branch ниже."
),
"reviewer": (
"Завершил "
"ревью "
"изменений."
),
"tester": (
"Завершил "
"прогон "
"тестов."
),
"deployer": (
"Завершил деплой."
),
}
# Analyst-specific candidate artifact list (label -> filename in docs/work-items/<wid>/).
# Matches the legacy _build_analyst_ready_comment list 1:1 so the BUG-C
# regression test (tests/test_analyst_comment.py) keeps passing under the
# unified format.
_ANALYST_CANDIDATES = [
("Business request", "00-business-request.md"),
("BRD", "01-brd.md"),
("ТЗ (TRZ)", "02-trz.md"),
("Acceptance Criteria", "03-acceptance-criteria.md"),
("Test Plan", "04-test-plan.yaml"),
("UI Test Cases", "04b-ui-test-cases.md"),
]
def _read_verdict_line(
agent: str, stage: str | None, worktree_root: str | None, work_item_id: str | None
) -> str | None:
"""Render the optional Verdict / Status line for reviewer / tester / deployer.
Sources (machine-readable YAML frontmatter, via src/frontmatter.py):
reviewer -> 12-review.md verdict: -> 'Verdict: <VALUE>'
tester -> 13-test-report.md verdict: (or status:) -> 'Verdict: <VALUE>'
deployer -> deploy-staging -> 15-staging-log.md staging_status: -> 'Status: <VALUE>'
else (deploy) -> 14-deploy-log.md deploy_status: -> 'Status: <VALUE>'
Returns None (line suppressed) for analyst / architect / developer, when
the worktree is unknown, the work-item id is missing, the artifact file is
absent, or the relevant frontmatter key is not present. Never raises.
"""
if agent not in ("reviewer", "tester", "deployer"):
return None
if not worktree_root or not work_item_id:
return None
try:
import os as _os
from .frontmatter import read_frontmatter_value
base_dir = _os.path.join(worktree_root, "docs/work-items", work_item_id)
except Exception:
return None
if agent == "reviewer":
v = read_frontmatter_value(_os.path.join(base_dir, "12-review.md"), "verdict")
return f"Verdict: {v}" if v else None
if agent == "tester":
path = _os.path.join(base_dir, "13-test-report.md")
v = read_frontmatter_value(path, "verdict")
if not v:
v = read_frontmatter_value(path, "status")
return f"Verdict: {v}" if v else None
# deployer
if (stage or "").strip() == "deploy-staging":
v = read_frontmatter_value(
_os.path.join(base_dir, "15-staging-log.md"), "staging_status"
)
else:
v = read_frontmatter_value(
_os.path.join(base_dir, "14-deploy-log.md"), "deploy_status"
)
return f"Status: {v}" if v else None
def _analyst_doc_items(
repo: str, branch: str, work_item_id: str, worktree_root: str | None
) -> list[str]:
"""Build the analyst's <li><a>...</a></li> list (mirrors legacy behaviour).
Files absent from the worktree are skipped (graceful, as in BUG-C / PR #13).
"""
if not (repo and branch and work_item_id):
return []
from .config import settings as _settings
owner = getattr(_settings, "gitea_owner", "admin")
base = (
getattr(_settings, "gitea_public_url", "") or getattr(_settings, "gitea_url", "")
).rstrip("/")
if not base:
return []
rel_dir = f"docs/work-items/{work_item_id}"
items: list[str] = []
for label, fname in _ANALYST_CANDIDATES:
if worktree_root:
try:
import os as _os
if not _os.path.isfile(_os.path.join(worktree_root, rel_dir, fname)):
continue
except Exception:
# On filesystem error, fall through and link the candidate anyway
# (best-effort) rather than blanking the whole document list.
pass
href = f"{base}/{owner}/{repo}/src/branch/{branch}/{rel_dir}/{fname}"
items.append(f'<li><a href="{href}">{label}</a></li>')
return items
def _usage_tail(usage: dict | None) -> str | None:
"""Render the technical token/cost tail (``<sub>...</sub>``) or None when empty.
Format (ADR-001 §3): ``<sub>{fmt_in} / {out} out · {cost}</sub>``.
Returns None when usage is missing entirely AND all of the relevant
components are zero — i.e. nothing meaningful to print.
"""
if not usage:
return None
in_total = _input_total(usage)
try:
out = int(usage.get("output_tokens") or 0)
except (TypeError, ValueError):
out = 0
try:
cost = float(usage.get("cost_usd") or 0.0)
except (TypeError, ValueError):
cost = 0.0
if in_total == 0 and out == 0 and cost == 0.0:
return None
return f"<sub>{fmt_in(usage)} / {fmt_tokens(out)} out · {fmt_cost(cost)}</sub>"
def build_status_comment(
agent: str,
*,
repo: str | None = None,
branch: str | None = None,
work_item_id: str | None = None,
pr_number=None,
stage: str | None = None,
usage: dict | None = None,
duration_s=None,
task_id=None,
worktree_root: str | None = None,
) -> str:
"""Build the unified per-agent finish comment (ORCH-016 / ADR-001).
Single hot path for every agent's "I just finished a stage" comment in
Plane. Replaces the old ``usage_comment(...)`` one-liner AND the analyst-
special ``stage_engine._build_analyst_ready_comment(...)`` HTML; both now
flow through here. Format (HTML, rendered by Plane), separated by ``<br>``::
{ICON} {RoleName}{DESCRIPTION}
[Verdict|Status: VALUE] # reviewer/tester/deployer + FM
[Длительность: 4m 12s]
<b>Документы:</b><ul><li><a href="...">label</a></li>...</ul>
[<sub>8.5M in / 45.8k out · $7.29</sub>]
Arguments (all keyword-only except ``agent``):
agent one of analyst|architect|developer|reviewer|tester|deployer.
Unknown agents get a generic header — defensive.
repo/branch repository name + feature branch. Required for artifact
links; without them the Документы block is omitted.
work_item_id Plane work-item id used as the docs/work-items/<id>/ slug.
pr_number developer only — appended as a PR link when set.
stage deployer only — 'deploy' vs 'deploy-staging' picks the
log file (14- vs 15-) and the verdict frontmatter key.
usage parsed token/cost dict (from parse_usage_from_text). When
None or all-zero the ``<sub>`` tail is suppressed.
duration_s explicit per-agent wall-clock seconds. If None and
task_id is given, falls back to
get_agent_duration(task_id, agent). Negative / non-int
values are treated as unknown.
task_id tasks.id — required for the DB duration fallback. The
verdict / artifact code paths do NOT depend on it.
worktree_root path to the task's git worktree. Drives AC-8 graceful
skipping of missing files AND the verdict frontmatter
read. Omit (None) in unit tests where only format matters.
The function MUST NOT raise — at worst it returns a degraded one-liner
header, with the exception logged. Self-hosting risk R-1: a crash here
blinds the stakeholder for that very ORCH task.
"""
try:
name = AGENT_DISPLAY.get(agent, (agent or "agent").capitalize())
icon = AGENT_ICON.get(agent, "")
description = _AGENT_DESCRIPTIONS.get(
agent,
"завершил стадию.",
)
if agent == "deployer":
if (stage or "").strip() == "deploy-staging":
description = (
"Завершил "
"staging-деплой."
)
elif (stage or "").strip() == "deploy":
description = (
"Завершил "
"прод-деплой."
)
lines: list[str] = [f"{icon} {name}{description}"]
verdict_line = _read_verdict_line(agent, stage, worktree_root, work_item_id)
if verdict_line:
lines.append(verdict_line)
# Duration: explicit param wins; otherwise DB fallback (ADR-001 §6).
resolved_duration: int | None = None
if duration_s is not None:
try:
if int(duration_s) >= 0:
resolved_duration = int(duration_s)
except (TypeError, ValueError):
resolved_duration = None
if resolved_duration is None and task_id is not None:
resolved_duration = get_agent_duration(task_id, agent)
d_text = fmt_duration(resolved_duration)
if d_text:
lines.append(
"Длительность: "
f"{d_text}"
)
# Documents block (analyst gets its full BRD/TRZ/AC/Test Plan list).
if agent == "analyst":
doc_items = _analyst_doc_items(
repo or "", branch or "", work_item_id or "", worktree_root
)
else:
doc_items = artifact_links(
agent, repo, branch, work_item_id, pr_number,
stage=stage, worktree_root=worktree_root,
)
if doc_items:
lines.append(
"<b>Документы:</b><ul>"
+ "".join(doc_items)
+ "</ul>"
)
tail = _usage_tail(usage)
if tail:
lines.append(tail)
return "<br>".join(lines)
except Exception as e: # defensive — R-1 fallback
logger.exception(f"build_status_comment failed for agent={agent}: {e}")
try:
name = AGENT_DISPLAY.get(agent, str(agent).capitalize())
icon = AGENT_ICON.get(agent, "")
return (
f"{icon} {name} "
"готов"
)
except Exception:
return "✅ Agent готов"
def task_usage_summary(task_id: int) -> dict:
"""Aggregate agent_runs usage for a task.
@@ -876,14 +441,14 @@ def task_summary_comment(task_id: int) -> str:
s = task_usage_summary(task_id)
cached = s.get("total_cached", 0)
head_in = (
f"{fmt_tokens(s['total_in'])} вход ({fmt_tokens(cached)} cached)"
f"{fmt_tokens(s['total_in'])} \u0432\u0445\u043e\u0434 ({fmt_tokens(cached)} cached)"
if cached > 0
else f"{fmt_tokens(s['total_in'])} вход"
else f"{fmt_tokens(s['total_in'])} \u0432\u0445\u043e\u0434"
)
lines = [
f"\U0001f4ca Итого по задаче: "
f"\U0001f4ca \u0418\u0442\u043e\u0433\u043e \u043f\u043e \u0437\u0430\u0434\u0430\u0447\u0435: "
f"{head_in} / "
f"{fmt_tokens(s['total_out'])} выход · "
f"{fmt_tokens(s['total_out'])} \u0432\u044b\u0445\u043e\u0434 \u00b7 "
f"{fmt_cost(s['total_cost'])}"
]
for agent, ti, tc, to, cost in s["per_agent"]:
@@ -894,6 +459,6 @@ def task_summary_comment(task_id: int) -> str:
else f"{fmt_tokens(ti)} in"
)
lines.append(
f" {name}: {in_str} / {fmt_tokens(to)} out · {fmt_cost(cost)}"
f"\u2022 {name}: {in_str} / {fmt_tokens(to)} out \u00b7 {fmt_cost(cost)}"
)
return "\n".join(lines)

View File

@@ -1,100 +0,0 @@
"""ORCH-017 / TC-10: analysis-approved flow wires DB fields into the approve ping.
When the analyst's artifacts are ready, `_handle_analysis_approved_flow` sets the
issue In Review, posts the analyst comment, and calls `notify_approve_requested`.
This test drives that flow with all network side-effects mocked and asserts the
resulting Telegram ping carries the BRD + Plane links built from the task's DB
row (repo / branch / plane_issue_id), while the approval gate name and the
no-self-advance contract are unchanged (AC-1 / AC-2 / AC-8).
"""
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_approve_flow.db")
os.environ["ORCH_DB_PATH"] = _test_db
import pytest # noqa: E402
import src.db as db_module # noqa: E402
from src.db import init_db, get_db # noqa: E402
from src import notifications as N # noqa: E402
from src import stage_engine as SE # noqa: E402
_ORCH_PROJECT_ID = "8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a"
@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(monkeypatch):
conn = get_db()
cur = conn.execute(
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, title, "
"plane_issue_id) VALUES (?, ?, ?, ?, ?, ?, ?)",
("p1", "ORCH-017", "orchestrator",
"feature/ORCH-017-brd-plane-telegram", "analysis",
"Approve flow", "issue-uuid-7"),
)
tid = cur.lastrowid
conn.commit()
conn.close()
return tid
def test_tc10_approved_flow_builds_links_from_db(monkeypatch):
tid = _mk_task(monkeypatch)
# Settings that make both links resolvable.
s = N._get_settings()
monkeypatch.setattr(s, "gitea_public_url", "https://git.example.org", raising=False)
monkeypatch.setattr(s, "gitea_owner", "orchteam", raising=False)
monkeypatch.setattr(s, "plane_web_url", "https://plane.example.org", raising=False)
monkeypatch.setattr(s, "plane_workspace_slug", "acme", raising=False)
# Isolate every network/fs side-effect of the flow.
monkeypatch.setitem(SE.QG_CHECKS, "check_analysis_complete",
lambda repo, wid, branch: (True, "ok"))
monkeypatch.setattr(SE, "set_issue_in_review", lambda wid: None)
monkeypatch.setattr(SE, "plane_add_comment", lambda *a, **k: None)
monkeypatch.setattr(SE, "_build_analyst_ready_comment", lambda *a, **k: "c")
# Capture the approve ping; stub the tracker refresh.
calls = []
monkeypatch.setattr(N, "send_telegram",
lambda text, disable_notification=False: calls.append(text) or 1)
monkeypatch.setattr(N, "update_task_tracker", lambda task_id: None)
result = SE.AdvanceResult()
SE._handle_analysis_approved_flow(
tid, "analysis", "orchestrator", "ORCH-017",
"feature/ORCH-017-brd-plane-telegram", "analyst", result,
)
# Gate name + no-self-advance contract unchanged (AC-8).
assert result.qg_name == "check_analysis_approved"
assert result.note == "analysis-in-review"
assert result.advanced is False
# Exactly one ping carrying both links built from the DB row (AC-1 / AC-2).
assert len(calls) == 1
text = calls[0]
assert (
"https://git.example.org/orchteam/orchestrator/src/branch/"
"feature/ORCH-017-brd-plane-telegram/docs/work-items/ORCH-017/01-brd.md"
) in text
assert (
f"https://plane.example.org/acme/projects/{_ORCH_PROJECT_ID}"
f"/issues/issue-uuid-7/"
) in text

View File

@@ -1,126 +0,0 @@
"""ORCH-016 / TC-11 + AC-6: analyst status-comment regression.
Status-only verdict model from PR #12 / #13 must be preserved exactly:
- the analyst comment still asks the stakeholder for the **Approved** status,
- it still rejects the obsolete ``:approved:`` reaction and "move to In Progress",
- it still links the documents that actually exist (BRD / TRZ / AC / Test Plan,
skipping anything not on disk),
- it now also carries the new «Длительность: …» line when an agent_runs row
exists for (task_id, analyst).
"""
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_orch016_analyst_regression.db")
os.environ["ORCH_DB_PATH"] = _test_db
import pytest # noqa: E402
from src import db as db_module # noqa: E402
from src.db import init_db, get_db # 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 _seed_task_and_analyst_run(task_id=42, agent="analyst", duration_seconds=180):
"""Insert a task and a finished analyst run with a measurable duration."""
conn = get_db()
conn.execute(
"INSERT INTO tasks (id, repo, branch, stage, work_item_id) "
"VALUES (?, 'orchestrator', 'feature/ORCH-016', 'analysis', 'ORCH-016')",
(task_id,),
)
conn.execute(
"INSERT INTO agent_runs (task_id, agent, started_at, finished_at) "
"VALUES (?, ?, datetime('now', ?), datetime('now'))",
(task_id, agent, f"-{duration_seconds} seconds"),
)
conn.commit()
conn.close()
def test_tc11_analyst_text_preserved_with_links(monkeypatch, tmp_path):
"""Analyst comment must keep all existing assertions from PR #12 / #13."""
from src import stage_engine as SE
from src.config import settings
wt = tmp_path / "wt"
docs = wt / "docs" / "work-items" / "ET-011"
docs.mkdir(parents=True)
for fname in (
"00-business-request.md", "01-brd.md", "02-trz.md",
"03-acceptance-criteria.md", "04-test-plan.yaml",
):
(docs / fname).write_text("x")
# 04b-ui-test-cases.md intentionally absent
monkeypatch.setattr(SE, "get_worktree_path", lambda repo, branch: str(wt))
monkeypatch.setattr(settings, "gitea_url", "http://localhost:3000", raising=False)
monkeypatch.setattr(
settings, "gitea_public_url", "https://git.mva154.duckdns.org", raising=False
)
monkeypatch.setattr(settings, "gitea_owner", "admin", raising=False)
html = SE._build_analyst_ready_comment(
"enduro-trails", "ET-011", "feature/ET-011-gpx-upload-feature",
)
# Status-only verdict text (PR #12 contract).
assert "Approved" in html
assert "Rejected" in html
assert ":approved:" not in html
assert "In Progress" not in html
# Clickable links via public URL only.
assert "<a href=" in html
base = ("https://git.mva154.duckdns.org/admin/enduro-trails/src/branch/"
"feature/ET-011-gpx-upload-feature/docs/work-items/ET-011/")
assert base + "01-brd.md" in html
assert base + "04-test-plan.yaml" in html
# Missing file NOT linked.
assert "04b-ui-test-cases.md" not in html
# Internal URL must NOT leak into clickable links.
assert "localhost:3000" not in html
def test_tc11_analyst_includes_duration_when_db_has_run(monkeypatch, tmp_path):
"""When an agent_runs row exists for (task_id, analyst), the comment carries
a «Длительность:» line populated via the DB fallback (AC-14)."""
from src import stage_engine as SE
from src.config import settings
wt = tmp_path / "wt"
(wt / "docs" / "work-items" / "ORCH-016").mkdir(parents=True)
(wt / "docs" / "work-items" / "ORCH-016" / "01-brd.md").write_text("x")
_seed_task_and_analyst_run(task_id=42, agent="analyst", duration_seconds=125)
monkeypatch.setattr(SE, "get_worktree_path", lambda repo, branch: str(wt))
monkeypatch.setattr(settings, "gitea_url", "http://localhost:3000", raising=False)
monkeypatch.setattr(settings, "gitea_public_url", "", raising=False)
monkeypatch.setattr(settings, "gitea_owner", "admin", raising=False)
html = SE._build_analyst_ready_comment(
"orchestrator", "ORCH-016", "feature/ORCH-016", task_id=42,
)
# Two-digit seconds rounding may shave ~1s — accept either neighbour.
assert any(
s in html
for s in ("Длительность: 2m 05s", "Длительность: 2m 04s", "Длительность: 2m 06s")
), html

View File

@@ -1,135 +0,0 @@
"""ORCH-016 / TC-16 + AC-6: analyst status-only regression.
Status-only verdict model (PR #12 / #13):
- analyst finishes its run -> Plane state becomes In Review,
- ONE status comment is posted asking the stakeholder to flip the status to
Approved (or write a reason and switch to Rejected),
- NO auto-advance happens — the next stage waits for human approval.
The ORCH-016 PR refactors the comment text into the unified status-comment
helper. This regression test guards against:
(a) the analyst path silently auto-advancing,
(b) the analyst comment losing the «Approved» / «Rejected» instruction text,
(c) the comment switching authorship away from the analyst bot.
We exercise `_handle_analysis_approved_flow` directly (the launcher path).
"""
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_orch016_analyst_so.db")
os.environ["ORCH_DB_PATH"] = _test_db
import pytest # noqa: E402
from src import db as db_module # noqa: E402
from src.db import init_db, get_db # noqa: E402
REPO = "enduro-trails"
BRANCH = "feature/ET-016-x"
WID = "ET-016"
@pytest.fixture(autouse=True)
def setup_db(monkeypatch, tmp_path):
monkeypatch.setattr(db_module.settings, "db_path", _test_db, raising=False)
if os.path.exists(_test_db):
os.unlink(_test_db)
init_db()
conn = get_db()
conn.execute(
"INSERT INTO tasks (id, repo, branch, stage, work_item_id) "
"VALUES (1, ?, ?, 'analysis', ?)",
(REPO, BRANCH, WID),
)
conn.commit()
conn.close()
yield
if os.path.exists(_test_db):
os.unlink(_test_db)
@pytest.fixture
def fake_worktree(monkeypatch, tmp_path):
base = tmp_path / "wt"
docs = base / "docs" / "work-items" / WID
docs.mkdir(parents=True)
# All analyst artifacts present -> "files_check" returns True.
for f in ("01-brd.md", "02-trz.md", "03-acceptance-criteria.md",
"04-test-plan.yaml"):
(docs / f).write_text("x")
monkeypatch.setattr("src.git_worktree.get_worktree_path", lambda r, b: str(base))
monkeypatch.setattr("src.stage_engine.get_worktree_path", lambda r, b: str(base))
monkeypatch.setattr("src.qg.checks.get_worktree_path", lambda r, b: str(base))
return base
@pytest.fixture
def collect_calls(monkeypatch):
calls = {"in_review": 0, "advance": 0, "comments": [], "enqueued": []}
monkeypatch.setattr(
"src.stage_engine.set_issue_in_review",
lambda wid: calls.__setitem__("in_review", calls["in_review"] + 1),
)
monkeypatch.setattr(
"src.stage_engine.notify_approve_requested", lambda tid: None
)
def _add_comment(wid, body, author=None, **kw):
calls["comments"].append({"wid": wid, "body": body, "author": author})
monkeypatch.setattr("src.stage_engine.plane_add_comment", _add_comment)
# advance_stage isn't directly hit; if anything calls update_task_stage to
# 'architecture', we'd see it here.
def _update_task_stage(task_id, stage):
calls["advance"] += 1
monkeypatch.setattr("src.stage_engine.update_task_stage", _update_task_stage)
def _enqueue(*a, **k):
calls["enqueued"].append((a, k))
return 1
monkeypatch.setattr("src.stage_engine.enqueue_job", _enqueue)
return calls
def test_tc16_analyst_goes_to_in_review_no_advance(fake_worktree, collect_calls):
"""When the analyst finishes with complete artifacts, the task goes to In
Review and NO advance/enqueue happens — the human approves via Plane status.
"""
from src.stage_engine import _handle_analysis_approved_flow, AdvanceResult
result = AdvanceResult(from_stage="analysis")
_handle_analysis_approved_flow(
task_id=1, current_stage="analysis", repo=REPO, work_item_id=WID,
branch=BRANCH, agent="analyst", result=result,
)
# In Review state requested in Plane.
assert collect_calls["in_review"] == 1, collect_calls
# NO stage-machine advance.
assert collect_calls["advance"] == 0, collect_calls
# NO new job enqueued by the analyst path.
assert collect_calls["enqueued"] == [], collect_calls
# Exactly one comment posted, authored by analyst, with required text bits.
assert len(collect_calls["comments"]) == 1, collect_calls["comments"]
c = collect_calls["comments"][0]
assert c["wid"] == WID
assert c["author"] == "analyst"
body = c["body"]
assert "Approved" in body
assert "Rejected" in body
assert ":approved:" not in body
assert "In Progress" not in body
# AC-6 +: the new unified format adds a Длительность line (DB fallback).
# No agent_runs row exists in this test, so the line should be ABSENT.
assert "Длительность" not in body

View File

@@ -1,68 +0,0 @@
"""ORCH-016 / AC-13 + AC-22: fmt_duration formatting contract.
Pure-function tests for the duration formatter used by build_status_comment.
No DB, no I/O — just the table in ADR-001 §8 / AC-13.
"""
import os
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
from src.usage import fmt_duration # noqa: E402
# ---------------------------------------------------------------------------
# TC-21: table-driven happy path (AC-13)
# ---------------------------------------------------------------------------
def test_fmt_duration_boundary_table():
cases = [
(0, "0s"),
(12, "12s"),
(59, "59s"),
(60, "1m 00s"),
(252, "4m 12s"),
(3599, "59m 59s"),
(3600, "1h 00m"),
(3780, "1h 03m"),
(10020, "2h 47m"),
]
for seconds, expected in cases:
assert fmt_duration(seconds) == expected, (
f"fmt_duration({seconds}) -> {fmt_duration(seconds)!r}; expected {expected!r}"
)
# ---------------------------------------------------------------------------
# TC-22: None / negative -> empty string (caller drops the line) (AC-13)
# ---------------------------------------------------------------------------
def test_fmt_duration_none_returns_empty():
assert fmt_duration(None) == ""
def test_fmt_duration_negative_returns_empty():
assert fmt_duration(-1) == ""
assert fmt_duration(-3600) == ""
def test_fmt_duration_garbage_returns_empty():
# Non-coercible input must not raise (defensive).
assert fmt_duration("abc") == ""
assert fmt_duration([1, 2]) == ""
def test_fmt_duration_float_seconds_truncated():
# int(12.9) == 12 — integer truncation, not rounding.
assert fmt_duration(12.9) == "12s"
assert fmt_duration(61.4) == "1m 01s"
# ---------------------------------------------------------------------------
# Caller contract: empty string => the 'Длительность:' line is NOT printed.
# build_status_comment is unit-tested in test_status_comment_format; here we
# just sanity-check the helper used to gate that decision.
# ---------------------------------------------------------------------------
def test_empty_string_is_falsy():
assert not fmt_duration(None)
assert not fmt_duration(-5)
assert fmt_duration(0) # "0s" IS truthy: AC-13 wants the line printed

View File

@@ -1,284 +0,0 @@
"""ORCH-017: tests for the direct BRD + Plane links in the approve-gate ping.
`notify_approve_requested` builds ONE notifying Telegram message that embeds:
* a Gitea branch-view link to docs/work-items/<WI>/01-brd.md (AC-1)
* a Plane issue browser link (AC-2)
Both links use external base URLs with documented fallbacks (AC-3), degrade
gracefully when data is missing / the Plane base is loopback (AC-6), keep the
'flip to Approved' call to action (AC-4), send exactly one notifying message
(AC-5) and stay HTML-safe (AC-7).
Network is isolated: send_telegram is replaced with an in-test recorder, the DB
is a temp SQLite seeded by a fixture. Mapping to acceptance criteria is in each
test's docstring (test ids TC-01..TC-08 from 04-test-plan.yaml).
"""
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_approve_links.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 init_db, get_db # noqa: E402
from src import notifications as N # noqa: E402
# Captured at import time, BEFORE the conftest autouse fixture stubs it to a
# no-op, so TC-08 can exercise the REAL send_telegram (parse_mode=HTML) end-to-end.
_ORIG_SEND_TELEGRAM = N.send_telegram
# orchestrator repo -> default project registry uuid (src/projects.py).
_ORCH_PROJECT_ID = "8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a"
@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(wid="ORCH-017", repo="orchestrator",
branch="feature/ORCH-017-brd-plane-telegram",
plane_issue_id="11112222-3333-4444-5555-666677778888",
title="Links in approve ping", stage="analysis"):
conn = get_db()
cur = conn.execute(
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, title, "
"plane_issue_id) VALUES (?, ?, ?, ?, ?, ?, ?)",
("p1", wid, repo, branch, stage, title, plane_issue_id),
)
tid = cur.lastrowid
conn.commit()
conn.close()
return tid
def _set(monkeypatch, **kw):
"""Set settings attrs on the singleton notifications actually reads."""
s = N._get_settings()
for k, v in kw.items():
monkeypatch.setattr(s, k, v, raising=False)
def _record_send(monkeypatch):
"""Replace send_telegram with a recorder; returns the calls list."""
calls = []
def _fake(text, disable_notification=False):
calls.append({"text": text, "silent": disable_notification})
return 1
monkeypatch.setattr(N, "send_telegram", _fake)
# Tracker refresh is irrelevant here and would hit send_telegram too -> stub.
monkeypatch.setattr(N, "update_task_tracker", lambda task_id: None)
return calls
# --------------------------------------------------------------------------- #
# TC-01 — BRD link (Gitea branch-view), AC-1 / AC-3
# --------------------------------------------------------------------------- #
def test_tc01_brd_link_present(monkeypatch):
tid = _mk_task()
_set(monkeypatch, gitea_public_url="https://git.example.org",
gitea_url="http://localhost:3000", gitea_owner="orchteam")
calls = _record_send(monkeypatch)
N.notify_approve_requested(tid)
assert len(calls) == 1
text = calls[0]["text"]
expected = (
'https://git.example.org/orchteam/orchestrator/src/branch/'
'feature/ORCH-017-brd-plane-telegram/docs/work-items/ORCH-017/01-brd.md'
)
assert expected in text
assert f'<a href="{expected}">' in text # clickable, points at 01-brd.md
# --------------------------------------------------------------------------- #
# TC-02 — Plane issue link (external web URL + workspace + project + issue id)
# AC-2 / AC-3
# --------------------------------------------------------------------------- #
def test_tc02_plane_link_present(monkeypatch):
tid = _mk_task(plane_issue_id="abcd-issue-uuid")
_set(monkeypatch, plane_web_url="https://plane.example.org",
plane_api_url="http://localhost:8091", plane_workspace_slug="acme")
calls = _record_send(monkeypatch)
N.notify_approve_requested(tid)
text = calls[0]["text"]
expected = (
f"https://plane.example.org/acme/projects/{_ORCH_PROJECT_ID}"
f"/issues/abcd-issue-uuid/"
)
assert expected in text
assert f'<a href="{expected}">' in text
# --------------------------------------------------------------------------- #
# TC-03 — fallback chain: gitea_public_url -> gitea_url, plane_web_url -> plane_api_url
# AC-3
# --------------------------------------------------------------------------- #
def test_tc03_url_fallbacks(monkeypatch):
tid = _mk_task(plane_issue_id="iss-1")
_set(monkeypatch,
gitea_public_url="", gitea_url="https://git-fallback.example.org",
gitea_owner="orchteam",
plane_web_url="", plane_api_url="https://plane-fallback.example.org",
plane_workspace_slug="acme")
calls = _record_send(monkeypatch)
N.notify_approve_requested(tid)
text = calls[0]["text"]
# BRD link uses gitea_url fallback.
assert "https://git-fallback.example.org/orchteam/orchestrator/" in text
# Plane link uses plane_api_url fallback (non-loopback -> allowed).
assert (
f"https://plane-fallback.example.org/acme/projects/{_ORCH_PROJECT_ID}"
f"/issues/iss-1/"
) in text
# --------------------------------------------------------------------------- #
# TC-04 — the 'flip to Approved' call to action is preserved. AC-4
# --------------------------------------------------------------------------- #
def test_tc04_keeps_approved_call_to_action(monkeypatch):
tid = _mk_task()
_set(monkeypatch, gitea_public_url="https://git.example.org",
gitea_owner="orchteam", plane_web_url="https://plane.example.org",
plane_workspace_slug="acme")
calls = _record_send(monkeypatch)
N.notify_approve_requested(tid)
assert "Approved" in calls[0]["text"]
# --------------------------------------------------------------------------- #
# TC-05 — exactly one notifying (non-silent) message. AC-5
# --------------------------------------------------------------------------- #
def test_tc05_single_notifying_message(monkeypatch):
tid = _mk_task()
_set(monkeypatch, gitea_public_url="https://git.example.org",
gitea_owner="orchteam", plane_web_url="https://plane.example.org",
plane_workspace_slug="acme")
calls = _record_send(monkeypatch)
N.notify_approve_requested(tid)
assert len(calls) == 1
assert calls[0]["silent"] is not True # notifying ping, not silent
# --------------------------------------------------------------------------- #
# TC-06 — graceful: no branch / no plane_issue_id -> still one message, missing
# links omitted, no exception. AC-6
# --------------------------------------------------------------------------- #
def test_tc06_graceful_missing_branch_and_issue(monkeypatch):
tid = _mk_task(branch=None, plane_issue_id=None)
_set(monkeypatch, gitea_public_url="https://git.example.org",
gitea_owner="orchteam", plane_web_url="https://plane.example.org",
plane_workspace_slug="acme")
calls = _record_send(monkeypatch)
N.notify_approve_requested(tid) # must not raise
assert len(calls) == 1
text = calls[0]["text"]
assert "Approved" in text # message still sent
assert "01-brd.md" not in text # BRD link omitted (no branch)
assert "/issues/" not in text # Plane link omitted (no issue id)
# --------------------------------------------------------------------------- #
# TC-07 — Plane base unusable (web url empty + api url empty) -> Plane link
# dropped, BRD link stays, orchestrator survives. AC-6
# --------------------------------------------------------------------------- #
def test_tc07_plane_base_empty_drops_plane_link_keeps_brd(monkeypatch):
tid = _mk_task()
_set(monkeypatch, gitea_public_url="https://git.example.org",
gitea_owner="orchteam",
plane_web_url="", plane_api_url="", plane_workspace_slug="acme")
calls = _record_send(monkeypatch)
N.notify_approve_requested(tid)
text = calls[0]["text"]
assert "01-brd.md" in text # BRD link survives
assert "/issues/" not in text # Plane link dropped
def test_tc07b_loopback_plane_base_dropped(monkeypatch):
"""Loopback fallback (plane_api_url=localhost) must NOT emit a broken link."""
tid = _mk_task()
_set(monkeypatch, gitea_public_url="https://git.example.org",
gitea_owner="orchteam",
plane_web_url="", plane_api_url="http://localhost:8091",
plane_workspace_slug="acme")
calls = _record_send(monkeypatch)
N.notify_approve_requested(tid)
text = calls[0]["text"]
assert "localhost" not in text # no loopback URL leaks into the ping
assert "/issues/" not in text # Plane link dropped by loopback-guard
assert "01-brd.md" in text
# --------------------------------------------------------------------------- #
# TC-08 — HTML safety: parse_mode=HTML preserved + dynamic parts escaped + valid
# <a> markup. AC-7
# --------------------------------------------------------------------------- #
def test_tc08_html_escaped_and_valid_markup(monkeypatch):
# work_item_id with an ampersand exercises html.escape on the dynamic label.
tid = _mk_task(wid="ORCH&17")
_set(monkeypatch, gitea_public_url="https://git.example.org",
gitea_owner="orchteam", plane_web_url="https://plane.example.org",
plane_workspace_slug="acme")
calls = _record_send(monkeypatch)
N.notify_approve_requested(tid)
text = calls[0]["text"]
# Dynamic work_item_id escaped in the header (no raw '&' before a word).
assert "ORCH&amp;17" in text
# Well-formed anchor markup: equal number of opening/closing tags.
assert text.count("<a href=") == text.count("</a>")
assert text.count("<a href=") >= 1
def test_tc08b_send_telegram_keeps_parse_mode_html(monkeypatch):
"""End-to-end through the REAL send_telegram: payload still parse_mode=HTML."""
# Restore the genuine send_telegram (conftest stubbed it to a no-op).
monkeypatch.setattr(N, "send_telegram", _ORIG_SEND_TELEGRAM)
monkeypatch.setattr(N, "update_task_tracker", lambda task_id: None)
_set(monkeypatch, telegram_bot_token="T", telegram_chat_id="C",
gitea_public_url="https://git.example.org", gitea_owner="orchteam",
plane_web_url="https://plane.example.org", plane_workspace_slug="acme")
tid = _mk_task()
with patch("src.notifications.httpx") as hx:
resp = MagicMock()
resp.json.return_value = {"ok": True, "result": {"message_id": 9}}
hx.post.return_value = resp
N.notify_approve_requested(tid)
assert hx.post.call_count == 1
payload = hx.post.call_args.kwargs["json"]
assert payload["parse_mode"] == "HTML"
assert payload["disable_notification"] is False # notifying
assert "<a href=" in payload["text"]

View File

@@ -1,79 +0,0 @@
"""ORCH-016 / TC-18 + AC-7: notify_done / set_issue_done not regressed.
The final deploy -> done transition still posts the «✅ Task completed!»
comment under the deployer bot, alongside the new ORCH-016 status comment
the deployer publishes when it finishes the stage. The two comments are
independent — the status comment doesn't replace `notify_done`.
"""
import os
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
from src import plane_sync as PS # noqa: E402
def test_notify_done_constants_unchanged():
# Emoji + message body — pinned to lock the contract.
assert PS.EMOJI_DONE == ""
def test_notify_done_posts_completed_comment(monkeypatch):
"""plane_sync.notify_done still posts the ✅ Task completed! comment
authored by the deployer."""
captured = {}
def _spy_update(work_item_id, state, project_id=None):
captured["update"] = (work_item_id, state, project_id)
def _spy_add(work_item_id, body, project_id=None, author=None, **kw):
captured.setdefault("comments", []).append(
{"wid": work_item_id, "body": body, "author": author}
)
monkeypatch.setattr(PS, "update_issue_state", _spy_update)
monkeypatch.setattr(PS, "add_comment", _spy_add)
monkeypatch.setattr(PS, "_resolve_project_id", lambda wid, pid=None: "p-1")
PS.notify_done("ET-016")
assert captured["update"] == ("ET-016", "done", "p-1")
assert len(captured["comments"]) == 1
c = captured["comments"][0]
assert c["wid"] == "ET-016"
assert c["author"] == "deployer"
# Body untouched: emoji + canonical Russian/English copy.
assert "" in c["body"]
assert "Task completed" in c["body"]
def test_set_issue_done_still_exported():
"""set_issue_done must remain importable from plane_sync — stage_engine
line ~269 invokes it on deploy->done. ORCH-016 must not remove or rename it.
"""
assert callable(getattr(PS, "set_issue_done", None))
# And stage_engine still imports it at the module level (regression: ORCH-016
# touches stage_engine to wire the new analyst comment helper).
from src import stage_engine as SE
assert getattr(SE, "set_issue_done", None) is PS.set_issue_done
def test_orch016_does_not_steal_done_signal(monkeypatch):
"""build_status_comment is just a comment — it must NOT call set_issue_done
or notify_done as a side effect (that's stage_engine's job)."""
from src import usage as U
called = {"done": 0, "in_review": 0}
def _fail(*a, **k):
called["done"] += 1
monkeypatch.setattr(PS, "set_issue_done", _fail)
monkeypatch.setattr(PS, "notify_done", _fail)
html = U.build_status_comment(
"deployer", repo="enduro-trails", branch="b", work_item_id="ET-016",
stage="deploy", duration_s=12,
)
assert "\U0001f680 Deployer" in html
assert called["done"] == 0

View File

@@ -1,199 +0,0 @@
"""ORCH-016 / TC-13..TC-15: _post_usage_comments integration tests.
End-to-end (DB + filesystem worktree, no network) verification that
AgentLauncher._post_usage_comments:
- resolves the task by (repo, branch),
- threads the explicit duration_s into build_status_comment,
- posts exactly ONE status comment authored by the finishing agent,
- for deployer: ALSO posts the per-task usage summary (deployer authorship).
The actual Plane HTTP call (plane_sync.add_comment) is patched out; we only
check the (work_item_id, body, author) tuples the launcher passes to it.
"""
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_orch016_post_usage.db")
os.environ["ORCH_DB_PATH"] = _test_db
import pytest # noqa: E402
from src import db as db_module # noqa: E402
from src.db import init_db, get_db # noqa: E402
from src.agents.launcher import AgentLauncher # noqa: E402
REPO = "enduro-trails"
BRANCH = "feature/ET-016-x"
WID = "ET-016"
@pytest.fixture
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()
conn = get_db()
conn.execute(
"INSERT INTO tasks (id, repo, branch, stage, work_item_id) "
"VALUES (1, ?, ?, 'review', ?)",
(REPO, BRANCH, WID),
)
conn.commit()
conn.close()
yield
if os.path.exists(_test_db):
os.unlink(_test_db)
@pytest.fixture
def fake_worktree(monkeypatch, tmp_path):
"""Stub get_worktree_path inside the launcher module to a tmp_path location."""
wt = tmp_path / "wt"
(wt / "docs" / "work-items" / WID).mkdir(parents=True)
def _get_wt(repo, branch):
return str(wt)
# The launcher imports get_worktree_path lazily inside the function body
# (`from ..git_worktree import get_worktree_path`); patch the source module.
monkeypatch.setattr("src.git_worktree.get_worktree_path", _get_wt)
monkeypatch.setattr("src.usage._input_total", lambda u: 0) # quiet <sub> tail
return wt
@pytest.fixture
def capture_comments(monkeypatch):
posts = []
def _spy(work_item_id, body, author=None, **kwargs):
posts.append({"wid": work_item_id, "body": body, "author": author})
monkeypatch.setattr("src.agents.launcher.plane_add_comment", _spy)
return posts
@pytest.fixture
def public_url(monkeypatch):
from src.config import settings
monkeypatch.setattr(
settings, "gitea_public_url", "https://git.mva154.duckdns.org", raising=False
)
monkeypatch.setattr(settings, "gitea_url", "http://localhost:3000", raising=False)
monkeypatch.setattr(settings, "gitea_owner", "admin", raising=False)
# ---------------------------------------------------------------------------
# TC-13: reviewer comment.
# ---------------------------------------------------------------------------
def test_tc13_reviewer_posts_one_status_comment(
setup_db, fake_worktree, capture_comments, public_url
):
(fake_worktree / "docs" / "work-items" / WID / "12-review.md").write_text(
"---\nverdict: APPROVE\n---\nReviewed.",
)
AgentLauncher()._post_usage_comments(
run_id=99, agent="reviewer", repo=REPO, branch=BRANCH,
usage={"input_tokens": 1, "output_tokens": 1, "cost_usd": 0.01},
duration_s=180,
)
assert len(capture_comments) == 1
post = capture_comments[0]
assert post["wid"] == WID
assert post["author"] == "reviewer"
body = post["body"]
assert "\U0001f50e Reviewer" in body
assert "Verdict: APPROVE" in body
assert "Длительность: 3m 00s" in body
assert "12-review.md" in body
# ---------------------------------------------------------------------------
# TC-14: tester comment.
# ---------------------------------------------------------------------------
def test_tc14_tester_posts_one_status_comment(
setup_db, fake_worktree, capture_comments, public_url
):
(fake_worktree / "docs" / "work-items" / WID / "13-test-report.md").write_text(
"---\nverdict: PASS\n---\n",
)
AgentLauncher()._post_usage_comments(
run_id=100, agent="tester", repo=REPO, branch=BRANCH,
usage=None, duration_s=42,
)
assert len(capture_comments) == 1
post = capture_comments[0]
assert post["author"] == "tester"
body = post["body"]
assert "\U0001f9ea Tester" in body
assert "Verdict: PASS" in body
assert "Длительность: 42s" in body
# ---------------------------------------------------------------------------
# TC-15: deployer comment + per-task summary (two comments, both from deployer).
# ---------------------------------------------------------------------------
def test_tc15_deployer_posts_status_then_summary(
setup_db, fake_worktree, capture_comments, public_url
):
# Task stage = 'deploy' so build_status_comment uses 14-deploy-log.md.
conn = get_db()
conn.execute("UPDATE tasks SET stage='deploy' WHERE id=1")
conn.commit()
conn.close()
(fake_worktree / "docs" / "work-items" / WID / "14-deploy-log.md").write_text(
"---\ndeploy_status: SUCCESS\n---\nDeployed.",
)
AgentLauncher()._post_usage_comments(
run_id=101, agent="deployer", repo=REPO, branch=BRANCH,
usage={"input_tokens": 1, "output_tokens": 1, "cost_usd": 0.01},
duration_s=300,
)
# 2 comments: status + per-task summary.
assert len(capture_comments) == 2
status, summary = capture_comments
assert status["author"] == "deployer"
assert "Status: SUCCESS" in status["body"]
assert "Длительность: 5m 00s" in status["body"]
assert "14-deploy-log.md" in status["body"]
assert summary["author"] == "deployer"
# task_summary_comment header (Russian "Итого по задаче").
assert "\U0001f4ca" in summary["body"]
assert "Итого" in summary["body"]
def test_deployer_staging_picks_15_log(
setup_db, fake_worktree, capture_comments, public_url
):
conn = get_db()
conn.execute("UPDATE tasks SET stage='deploy-staging' WHERE id=1")
conn.commit()
conn.close()
(fake_worktree / "docs" / "work-items" / WID / "15-staging-log.md").write_text(
"---\nstaging_status: SUCCESS\n---\n",
)
AgentLauncher()._post_usage_comments(
run_id=102, agent="deployer", repo=REPO, branch=BRANCH,
usage=None, duration_s=10,
)
# deployer always also posts the summary; check the FIRST comment is status.
assert len(capture_comments) == 2
status = capture_comments[0]
assert "Status: SUCCESS" in status["body"]
assert "15-staging-log.md" in status["body"]
assert "14-deploy-log.md" not in status["body"]
assert "staging-деплой" in status["body"]

View File

@@ -1,64 +0,0 @@
"""ORCH-016 / TC-20 + AC-11: Quality Gates + stage machine are unchanged.
Smoke / change-detector test: the ORCH-016 PR touches comment formatting only.
The QG registry (src/qg/checks.QG_CHECKS) and the stage-machine table
(src/stages.STAGE_TRANSITIONS) MUST remain bit-identical to the contracts the
pipeline depends on. If a future change moves the comment hot path into these
files by accident, this guard breaks first.
"""
import os
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
from src.qg.checks import QG_CHECKS # noqa: E402
from src.stages import STAGE_TRANSITIONS # noqa: E402
# The set of QG names the pipeline DEPLOYS on. Order doesn't matter, identity does.
_EXPECTED_QGS = {
"check_analysis_approved",
"check_analysis_complete",
"check_architecture_done",
"check_ci_green",
"check_review_approved",
"check_tests_passed",
"check_reviewer_verdict",
"check_tests_local",
"check_deploy_status",
"check_staging_status",
}
def test_tc20_qg_registry_unchanged():
assert set(QG_CHECKS.keys()) == _EXPECTED_QGS
def test_tc20_qg_callables_unchanged():
# All entries must be callable — no stub / lambda / None.
for name, fn in QG_CHECKS.items():
assert callable(fn), f"QG {name} is not callable"
# Reference snapshot of STAGE_TRANSITIONS (mirrors what's in docs/architecture
# and src/stages.py — duplicated here on purpose as a regression yardstick).
_EXPECTED_TRANSITIONS = {
"created": {"next": "analysis", "agent": "analyst", "qg": None},
"analysis": {"next": "architecture", "agent": "architect", "qg": "check_analysis_approved"},
"architecture": {"next": "development", "agent": "developer", "qg": "check_architecture_done"},
"development": {"next": "review", "agent": "reviewer", "qg": "check_ci_green"},
"review": {"next": "testing", "agent": "tester", "qg": "check_reviewer_verdict"},
"testing": {"next": "deploy-staging", "agent": "deployer", "qg": "check_tests_passed"},
"deploy-staging": {"next": "deploy", "agent": "deployer", "qg": "check_staging_status"},
"deploy": {"next": "done", "agent": None, "qg": "check_deploy_status"},
"done": {"next": None, "agent": None, "qg": None},
}
def test_tc20_stage_transitions_unchanged():
assert STAGE_TRANSITIONS == _EXPECTED_TRANSITIONS, (
"STAGE_TRANSITIONS drift detected — ORCH-016 must not change the "
"stage machine. Touched stage_engine or stages.py? Update the snapshot "
"in a separate, intentional PR."
)

View File

@@ -1,138 +0,0 @@
"""ORCH-41: tests for resolve_agent_effort + effort validation + flag assembly.
Mirrors test_resolve_agent_model's 4-level priority for the --effort lever, and
adds:
- validation: a value outside {low,medium,high,xhigh,max} is dropped -> ""
- flag assembly: --model / --effort / --fallback-model are present/absent in
the built command exactly when the resolved value is non-empty.
"""
import os
import tempfile
import pytest
os.environ.setdefault("ORCH_DB_PATH",
os.path.join(tempfile.gettempdir(), "test_orch41_effort.db"))
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
from src.agents.launcher import (
resolve_agent_effort, resolve_agent_model, VALID_EFFORTS,
)
from src.config import settings
from src import projects as P
from src.projects import ProjectConfig, reload_projects
ORCH_PLANE_ID = "8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a"
@pytest.fixture(autouse=True)
def _clean_settings(monkeypatch):
monkeypatch.setattr(settings, "agent_effort_default", "high")
for a in ("analyst", "architect", "developer", "reviewer"):
monkeypatch.setattr(settings, f"agent_effort_{a}", "high")
for a in ("tester", "deployer"):
monkeypatch.setattr(settings, f"agent_effort_{a}", "medium")
monkeypatch.setattr(P.settings, "projects_json", "")
reload_projects()
yield
reload_projects()
def _install_registry(monkeypatch, agent_efforts):
reg = [ProjectConfig(
plane_project_id=ORCH_PLANE_ID, repo="orchestrator",
work_item_prefix="ORCH", name="orchestrator",
agent_efforts=agent_efforts,
)]
monkeypatch.setattr(P, "PROJECTS", reg)
monkeypatch.setattr(P, "_BY_PLANE_ID", {p.plane_project_id: p for p in reg})
monkeypatch.setattr(P, "_BY_REPO", {p.repo: p for p in reg})
# ---- default split ----------------------------------------------------------
def test_default_split():
assert resolve_agent_effort("developer") == "high"
assert resolve_agent_effort("architect") == "high"
assert resolve_agent_effort("tester") == "medium"
assert resolve_agent_effort("deployer") == "medium"
# ---- level 4: nothing -> "" -------------------------------------------------
def test_no_config_returns_empty(monkeypatch):
monkeypatch.setattr(settings, "agent_effort_default", "")
monkeypatch.setattr(settings, "agent_effort_tester", "")
assert resolve_agent_effort("tester") == ""
# ---- level 2: per-agent env beats default -----------------------------------
def test_per_agent_env(monkeypatch):
monkeypatch.setattr(settings, "agent_effort_tester", "low")
assert resolve_agent_effort("tester") == "low"
# ---- level 1: project override wins -----------------------------------------
def test_project_override(monkeypatch):
monkeypatch.setattr(settings, "agent_effort_developer", "high")
_install_registry(monkeypatch, {"developer": "xhigh"})
assert resolve_agent_effort("developer", ORCH_PLANE_ID) == "xhigh"
assert resolve_agent_effort("developer") == "high"
# ---- validation: invalid value dropped --------------------------------------
def test_invalid_default_dropped(monkeypatch):
monkeypatch.setattr(settings, "agent_effort_developer", "")
monkeypatch.setattr(settings, "agent_effort_default", "turbo")
assert resolve_agent_effort("developer") == ""
def test_invalid_env_dropped(monkeypatch):
monkeypatch.setattr(settings, "agent_effort_reviewer", "ultra")
assert resolve_agent_effort("reviewer") == ""
def test_invalid_project_override_dropped(monkeypatch):
_install_registry(monkeypatch, {"developer": "bogus"})
assert resolve_agent_effort("developer", ORCH_PLANE_ID) == ""
def test_all_valid_efforts_pass(monkeypatch):
monkeypatch.setattr(settings, "agent_effort_developer", "")
for e in VALID_EFFORTS:
monkeypatch.setattr(settings, "agent_effort_default", e)
assert resolve_agent_effort("developer") == e
# ---- flag assembly (mirror of launcher cmd construction) --------------------
def _build_flags(model, effort, fb):
model_flag = f"--model {model} " if model else ""
effort_flag = f"--effort {effort} " if effort else ""
fb_flag = f"--fallback-model {fb} " if fb else ""
return f"{model_flag}{effort_flag}{fb_flag}"
def test_flags_present_when_configured(monkeypatch):
monkeypatch.setattr(settings, "agent_fallback_model", "claude-sonnet-4-6")
model = resolve_agent_model("developer")
effort = resolve_agent_effort("developer")
fb = settings.agent_fallback_model
flags = _build_flags(model, effort, fb)
assert "--model claude-opus-4-8 " in flags
assert "--effort high " in flags
assert "--fallback-model claude-sonnet-4-6 " in flags
def test_flags_absent_when_empty(monkeypatch):
monkeypatch.setattr(settings, "agent_model_default", "")
monkeypatch.setattr(settings, "agent_model_developer", "")
monkeypatch.setattr(settings, "agent_effort_default", "")
monkeypatch.setattr(settings, "agent_effort_developer", "")
monkeypatch.setattr(settings, "agent_fallback_model", "")
model = resolve_agent_model("developer")
effort = resolve_agent_effort("developer")
fb = settings.agent_fallback_model
flags = _build_flags(model, effort, fb)
assert flags == ""
assert "--model" not in flags
assert "--effort" not in flags
assert "--fallback-model" not in flags

View File

@@ -1,156 +0,0 @@
"""ORCH-41: tests for resolve_agent_model (per-agent + per-project LLM model).
Covers the 4-level resolution priority:
1. ProjectConfig.agent_models[agent] (per-project override, from projects_json)
2. settings.agent_model_<agent> (per-agent env, when non-empty)
3. settings.agent_model_default (global default)
4. "" (no override anywhere -> CLI default)
plus: unknown project_id / no project_id skips level 1, unknown agent skips
level 2, and the frozen ProjectConfig still accepts agent_models (default {}).
We never mutate the module-global registry permanently: tests that need a
custom registry install one via monkeypatch + reload_projects and restore the
default afterwards (autouse fixture).
"""
import os
import tempfile
import pytest
os.environ.setdefault("ORCH_DB_PATH",
os.path.join(tempfile.gettempdir(), "test_orch41_model.db"))
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
from src.agents.launcher import resolve_agent_model
from src.config import settings
from src import projects as P
from src.projects import ProjectConfig, reload_projects, _parse_projects_json
ORCH_PLANE_ID = "8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a"
ENDURO_PLANE_ID = "7a79f0a9-5278-49cd-9007-9a338f238f9c"
@pytest.fixture(autouse=True)
def _clean_settings(monkeypatch):
"""Reset all per-agent/default model settings to a known baseline so tests
are order-independent regardless of what other modules set in the env."""
monkeypatch.setattr(settings, "agent_model_default", "claude-opus-4-8")
for a in ("analyst", "architect", "developer", "reviewer", "tester", "deployer"):
monkeypatch.setattr(settings, f"agent_model_{a}", "")
# default registry (no per-project overrides)
monkeypatch.setattr(P.settings, "projects_json", "")
reload_projects()
yield
reload_projects()
def _install_registry(monkeypatch, agent_models):
"""Install a single-project registry for ORCH with the given agent_models."""
reg = [ProjectConfig(
plane_project_id=ORCH_PLANE_ID, repo="orchestrator",
work_item_prefix="ORCH", name="orchestrator",
agent_models=agent_models,
)]
monkeypatch.setattr(P, "PROJECTS", reg)
monkeypatch.setattr(P, "_BY_PLANE_ID", {p.plane_project_id: p for p in reg})
monkeypatch.setattr(P, "_BY_REPO", {p.repo: p for p in reg})
# ---- Level 4: nothing configured -> "" --------------------------------------
def test_no_config_returns_empty(monkeypatch):
monkeypatch.setattr(settings, "agent_model_default", "")
assert resolve_agent_model("developer") == ""
assert resolve_agent_model("developer", ORCH_PLANE_ID) == ""
# ---- Level 3: global default ------------------------------------------------
def test_global_default():
assert resolve_agent_model("developer") == "claude-opus-4-8"
assert resolve_agent_model("architect") == "claude-opus-4-8"
# ---- Level 2: per-agent env beats default -----------------------------------
def test_per_agent_env_overrides_default(monkeypatch):
monkeypatch.setattr(settings, "agent_model_reviewer", "claude-sonnet-4-6")
assert resolve_agent_model("reviewer") == "claude-sonnet-4-6"
# other agents still fall through to default
assert resolve_agent_model("developer") == "claude-opus-4-8"
# ---- Level 1: per-project override beats per-agent env and default ----------
def test_project_override_beats_env_and_default(monkeypatch):
monkeypatch.setattr(settings, "agent_model_developer", "claude-sonnet-4-6")
_install_registry(monkeypatch, {"developer": "claude-opus-4-8"})
assert resolve_agent_model("developer", ORCH_PLANE_ID) == "claude-opus-4-8"
# without project_id, falls back to per-agent env
assert resolve_agent_model("developer") == "claude-sonnet-4-6"
def test_project_override_only_for_listed_agent(monkeypatch):
_install_registry(monkeypatch, {"developer": "claude-opus-4-8"})
# reviewer not in agent_models -> falls back to default
assert resolve_agent_model("reviewer", ORCH_PLANE_ID) == "claude-opus-4-8"
monkeypatch.setattr(settings, "agent_model_reviewer", "claude-sonnet-4-6")
assert resolve_agent_model("reviewer", ORCH_PLANE_ID) == "claude-sonnet-4-6"
# ---- unknown / empty project id skips level 1 -------------------------------
def test_unknown_project_id_skips_override(monkeypatch):
_install_registry(monkeypatch, {"developer": "x-model"})
assert resolve_agent_model("developer", "no-such-uuid") == "claude-opus-4-8"
assert resolve_agent_model("developer", None) == "claude-opus-4-8"
# ---- unknown agent skips per-agent env, still gets default ------------------
def test_unknown_agent_falls_to_default():
assert resolve_agent_model("nonexistent") == "claude-opus-4-8"
# ---- frozen ProjectConfig accepts agent_models ------------------------------
def test_projectconfig_frozen_with_agent_models():
pc = ProjectConfig(
plane_project_id="x", repo="r", work_item_prefix="P", name="n",
agent_models={"developer": "m"},
)
assert pc.agent_models == {"developer": "m"}
# default is an empty dict, not shared/mutable across instances
pc2 = ProjectConfig(plane_project_id="y", repo="r2",
work_item_prefix="P2", name="n2")
assert pc2.agent_models == {}
assert pc2.agent_models is not pc.agent_models
with pytest.raises(Exception):
pc.repo = "changed" # frozen
# ---- projects_json parsing of agent_models / agent_efforts ------------------
def test_parse_projects_json_with_overrides():
raw = (
'[{"plane_project_id":"p1","repo":"orchestrator",'
'"work_item_prefix":"ORCH",'
'"agent_models":{"developer":"claude-opus-4-8","reviewer":"claude-sonnet-4-6"},'
'"agent_efforts":{"developer":"xhigh","tester":"low"}}]'
)
parsed = _parse_projects_json(raw)
assert parsed is not None and len(parsed) == 1
pc = parsed[0]
assert pc.agent_models == {"developer": "claude-opus-4-8",
"reviewer": "claude-sonnet-4-6"}
assert pc.agent_efforts == {"developer": "xhigh", "tester": "low"}
def test_parse_projects_json_omitted_overrides_default_empty():
raw = ('[{"plane_project_id":"p1","repo":"r","work_item_prefix":"P"}]')
parsed = _parse_projects_json(raw)
assert parsed is not None and len(parsed) == 1
assert parsed[0].agent_models == {}
assert parsed[0].agent_efforts == {}
def test_parse_projects_json_malformed_override_ignored():
# agent_models is not an object -> dropped to {}, entry still valid
raw = ('[{"plane_project_id":"p1","repo":"r","work_item_prefix":"P",'
'"agent_models":"oops"}]')
parsed = _parse_projects_json(raw)
assert parsed is not None and parsed[0].agent_models == {}

View File

@@ -1,122 +0,0 @@
"""ORCH-016 / TC-19 + AC-1..AC-5 authorship: status comments use per-agent bots.
When a status comment is posted by AgentLauncher._post_usage_comments, the
underlying plane_sync.add_comment must be invoked with ``author=<agent>`` so
plane_sync._headers_for(<agent>) picks the agent's bot token
(PLANE_BOT_TOKENS[role]) — falling back to PLANE_HEADERS when the bot token
is empty / role unknown. Comment FORMAT changes (ORCH-016) must not affect
that authorship contract.
"""
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_orch016_authorship.db")
os.environ["ORCH_DB_PATH"] = _test_db
import pytest # noqa: E402
from src import db as db_module # noqa: E402
from src.db import init_db, get_db # noqa: E402
from src.agents.launcher import AgentLauncher # noqa: E402
REPO = "enduro-trails"
BRANCH = "feature/ET-016-x"
WID = "ET-016"
@pytest.fixture
def 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()
conn = get_db()
conn.execute(
"INSERT INTO tasks (id, repo, branch, stage, work_item_id) "
"VALUES (1, ?, ?, 'review', ?)",
(REPO, BRANCH, WID),
)
conn.commit()
conn.close()
yield
if os.path.exists(_test_db):
os.unlink(_test_db)
@pytest.fixture
def fake_wt(monkeypatch, tmp_path):
base = tmp_path / "wt"
(base / "docs" / "work-items" / WID).mkdir(parents=True)
monkeypatch.setattr("src.git_worktree.get_worktree_path", lambda r, b: str(base))
return base
@pytest.fixture
def capture(monkeypatch):
posts = []
def _spy(work_item_id, body, author=None, **kwargs):
posts.append({"wid": work_item_id, "body": body, "author": author})
monkeypatch.setattr("src.agents.launcher.plane_add_comment", _spy)
return posts
@pytest.mark.parametrize("agent", ["architect", "developer", "reviewer", "tester"])
def test_tc19_status_comment_carries_agent_author(agent, db, fake_wt, capture):
"""Each agent's status comment must be POST-ed under that agent's bot."""
AgentLauncher()._post_usage_comments(
run_id=1, agent=agent, repo=REPO, branch=BRANCH,
usage=None, duration_s=10,
)
assert len(capture) >= 1
assert capture[0]["author"] == agent, (
f"Expected author={agent!r}, got {capture[0]['author']!r}"
)
def test_tc19_deployer_status_and_summary_both_authored_by_deployer(db, fake_wt, capture):
"""Deployer posts TWO comments (status + per-task summary) — both ``author='deployer'``."""
conn = get_db()
conn.execute("UPDATE tasks SET stage='deploy' WHERE id=1")
conn.commit()
conn.close()
AgentLauncher()._post_usage_comments(
run_id=2, agent="deployer", repo=REPO, branch=BRANCH,
usage=None, duration_s=10,
)
assert len(capture) == 2
assert {c["author"] for c in capture} == {"deployer"}
def test_tc19_headers_for_unknown_role_falls_back(monkeypatch):
"""Ensure plane_sync._headers_for handles unknown agents (fallback contract)."""
from src import plane_sync
h = plane_sync._headers_for("unknown_role_xyz")
# PLANE_HEADERS fallback uses settings.plane_api_token (set to 'test-token').
assert isinstance(h, dict) and "X-API-Key" in h
def test_tc19_status_comment_format_preserves_author_contract(db, fake_wt, capture):
"""The ORCH-016 format change must not strip the author= kw from the call site."""
(fake_wt / "docs" / "work-items" / WID / "12-review.md").write_text(
"---\nverdict: APPROVE\n---\n",
)
AgentLauncher()._post_usage_comments(
run_id=3, agent="reviewer", repo=REPO, branch=BRANCH,
usage={"input_tokens": 0, "output_tokens": 0, "cost_usd": 0.0},
duration_s=180,
)
assert len(capture) == 1
post = capture[0]
assert post["author"] == "reviewer"
# And the new format is present in the body (sanity).
assert "\U0001f50e Reviewer" in post["body"]
assert "Verdict: APPROVE" in post["body"]
assert "Длительность: 3m 00s" in post["body"]

View File

@@ -1,124 +0,0 @@
"""ORCH-016 / TC-17 + AC-7: status-comment de-dup contract.
The «one comment per agent per stage» guarantee is enforced upstream of
build_status_comment by:
- the webhook event-dedup table (events.delivery_id PARTIAL UNIQUE, ORCH-5 /
src.db.insert_event_dedup),
- the job queue claim-once contract (src.db.claim_next_job, ORCH-1).
The ORCH-016 PR introduces a new comment FORMAT but must not weaken these
guarantees. This regression test:
1. exercises insert_event_dedup directly to confirm the same delivery_id is
accepted exactly once (sanity for the dedup primitive),
2. exercises build_status_comment to confirm it is a PURE function (same
inputs -> same output), so a retried call from a poorly-isolated test or a
misbehaving caller doesn't silently produce two different comment bodies.
"""
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_orch016_dedup_regression.db")
os.environ["ORCH_DB_PATH"] = _test_db
import pytest # noqa: E402
from src import db as db_module # noqa: E402
from src.db import init_db, insert_event_dedup # 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)
# ---------------------------------------------------------------------------
# Primitive: event-dedup still rejects a re-delivered webhook.
# ---------------------------------------------------------------------------
def test_tc17_event_dedup_inserts_once_for_same_delivery_id():
"""Two webhook deliveries with the same delivery_id -> one row inserted.
First call returns True (new row); second call returns False (rejected).
This is the primitive every status-comment trigger relies on.
"""
assert insert_event_dedup("plane", "issue.updated", "{}", "delivery-XYZ") is True
assert insert_event_dedup("plane", "issue.updated", "{}", "delivery-XYZ") is False
def test_tc17_event_dedup_distinguishes_delivery_ids():
"""Distinct delivery IDs are independent — two different webhooks both go through."""
assert insert_event_dedup("plane", "issue.updated", "{}", "delivery-A") is True
assert insert_event_dedup("plane", "issue.updated", "{}", "delivery-B") is True
# ---------------------------------------------------------------------------
# Format: build_status_comment is deterministic. A double-fire from buggy code
# still produces an IDENTICAL body -- so the upstream dedup primitive can
# safely treat the second call as no-op without comparing prose.
# ---------------------------------------------------------------------------
def test_tc17_build_status_comment_is_pure(tmp_path):
"""Same inputs produce byte-identical output (deterministic / side-effect free)."""
from src import usage as U
wt = tmp_path / "wt"
(wt / "docs" / "work-items" / "ET-016").mkdir(parents=True)
(wt / "docs" / "work-items" / "ET-016" / "12-review.md").write_text(
"---\nverdict: APPROVE\n---\n",
)
args = dict(
repo="enduro-trails",
branch="feature/ET-016-x",
work_item_id="ET-016",
duration_s=120,
worktree_root=str(wt),
usage={"input_tokens": 100, "output_tokens": 50, "cost_usd": 0.05},
)
a = U.build_status_comment("reviewer", **args)
b = U.build_status_comment("reviewer", **args)
c = U.build_status_comment("reviewer", **args)
assert a == b == c
def test_tc17_build_status_comment_no_db_side_effects(tmp_path):
"""A status-comment build must NOT write to the DB.
Otherwise a webhook-dedup hit would still touch state via the comment
builder. We check by counting rows in `tasks`/`agent_runs`/`jobs` before
and after.
"""
from src import usage as U
from src.db import get_db
conn = get_db()
counts_before = [
conn.execute("SELECT COUNT(*) FROM tasks").fetchone()[0],
conn.execute("SELECT COUNT(*) FROM agent_runs").fetchone()[0],
conn.execute("SELECT COUNT(*) FROM jobs").fetchone()[0],
]
conn.close()
U.build_status_comment(
"developer", repo="enduro-trails", branch="b",
work_item_id="ET-016", pr_number=1, duration_s=10,
usage={"input_tokens": 1, "output_tokens": 1, "cost_usd": 0.01},
)
conn = get_db()
counts_after = [
conn.execute("SELECT COUNT(*) FROM tasks").fetchone()[0],
conn.execute("SELECT COUNT(*) FROM agent_runs").fetchone()[0],
conn.execute("SELECT COUNT(*) FROM jobs").fetchone()[0],
]
conn.close()
assert counts_before == counts_after

View File

@@ -1,145 +0,0 @@
"""ORCH-016 / TC-24 + TC-25 + AC-14: DB fallback for the duration line.
When build_status_comment is called WITHOUT an explicit duration_s but with a
task_id, it must:
- read the last finished agent_runs row for (task_id, agent),
- compute (julianday(finished_at) - julianday(started_at)) * 86400 in seconds,
- format it via fmt_duration and inject the «Длительность: …» line.
Failure modes (DB locked / row missing / NULL finished_at / negative diff) must
NEVER raise; they simply suppress the duration line and let the rest of the
comment publish.
"""
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_orch016_duration_fallback.db")
os.environ["ORCH_DB_PATH"] = _test_db
import pytest # noqa: E402
from src import db as db_module # noqa: E402
from src.db import init_db, get_db # noqa: E402
from src import usage as U # 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 _insert_run(task_id, agent, *, seconds_ago_start=None, finished=True):
"""Insert an agent_runs row with controllable timestamps."""
conn = get_db()
if seconds_ago_start is None:
conn.execute(
"INSERT INTO agent_runs (task_id, agent) VALUES (?, ?)",
(task_id, agent),
)
else:
if finished:
conn.execute(
"INSERT INTO agent_runs (task_id, agent, started_at, finished_at) "
"VALUES (?, ?, datetime('now', ?), datetime('now'))",
(task_id, agent, f"-{seconds_ago_start} seconds"),
)
else:
conn.execute(
"INSERT INTO agent_runs (task_id, agent, started_at) "
"VALUES (?, ?, datetime('now', ?))",
(task_id, agent, f"-{seconds_ago_start} seconds"),
)
conn.commit()
conn.close()
# ---------------------------------------------------------------------------
# TC-24: explicit duration_s missing -> DB lookup populates the line.
# ---------------------------------------------------------------------------
def test_tc24_fallback_reads_agent_runs_for_last_finished():
_insert_run(7, "reviewer", seconds_ago_start=240)
secs = U.get_agent_duration(7, "reviewer")
# SQLite's julianday math can be off by a second on either side.
assert secs is not None and abs(secs - 240) <= 1, secs
html = U.build_status_comment("reviewer", task_id=7)
assert any(
s in html for s in (
"Длительность: 4m 00s",
"Длительность: 4m 01s",
"Длительность: 3m 59s",
)
), html
def test_tc24_fallback_picks_last_run_when_multiple():
_insert_run(11, "developer", seconds_ago_start=120)
_insert_run(11, "developer", seconds_ago_start=10)
secs = U.get_agent_duration(11, "developer")
assert secs is not None and abs(secs - 10) <= 1, secs
def test_tc24_no_row_returns_none():
assert U.get_agent_duration(999, "tester") is None
def test_tc24_finished_at_null_returns_none():
_insert_run(13, "tester", seconds_ago_start=100, finished=False)
assert U.get_agent_duration(13, "tester") is None
def test_tc24_missing_args_returns_none():
assert U.get_agent_duration(None, "tester") is None
assert U.get_agent_duration(7, "") is None
assert U.get_agent_duration(0, "tester") is None
# ---------------------------------------------------------------------------
# TC-25: read failure -> logged at debug, NO exception, comment still ships.
# ---------------------------------------------------------------------------
def test_tc25_db_read_failure_no_raise(monkeypatch, caplog):
"""A locked / broken DB must not crash the status comment hot path."""
import logging
def _boom():
raise RuntimeError("simulated DB outage")
monkeypatch.setattr(U, "get_db", _boom)
with caplog.at_level(logging.DEBUG, logger="orchestrator.usage"):
assert U.get_agent_duration(1, "developer") is None
# build_status_comment must still publish (no duration line, no crash).
html = U.build_status_comment("developer", task_id=1, repo="r", branch="b")
assert "Длительность" not in html
assert "\U0001f4bb Developer" in html
# ---------------------------------------------------------------------------
# Sanity: explicit duration_s wins over DB fallback (no SELECT at all).
# ---------------------------------------------------------------------------
def test_explicit_duration_wins_over_db_fallback(monkeypatch):
called = {"n": 0}
real = U.get_agent_duration
def _spy(task_id, agent):
called["n"] += 1
return real(task_id, agent)
monkeypatch.setattr(U, "get_agent_duration", _spy)
_insert_run(5, "architect", seconds_ago_start=300)
html = U.build_status_comment(
"architect", task_id=5, duration_s=12, repo="r", branch="b",
)
assert "Длительность: 12s" in html
# Explicit value supplied -> DB fallback is short-circuited.
assert called["n"] == 0

View File

@@ -1,354 +0,0 @@
"""ORCH-016 / TC-01..TC-10, TC-12, TC-23: unified status comment format.
Unit tests for src.usage.build_status_comment(...) — the single hot path for
every agent's "I just finished a stage" comment in Plane (ADR-001).
Covers:
* Header per agent (icon + role + description from AC-1..AC-5).
* Verdict / Status line read from frontmatter (reviewer / tester / deployer).
* Длительность line shown when duration_s is supplied; suppressed otherwise.
* <a href="..."> link items per agent.
* URL base picks gitea_public_url, falls back to gitea_url.
* Graceful behaviour when files are missing / no frontmatter (AC-8).
No DB / no network — only the worktree filesystem (via tmp_path).
"""
import os
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
import pytest # noqa: E402
from src import usage as U # noqa: E402
WID = "ET-016"
REPO = "enduro-trails"
BRANCH = "feature/ET-016-status-comments"
@pytest.fixture(autouse=True)
def _set_urls(monkeypatch):
"""gitea_public_url is the canonical clickable base (AC-9)."""
monkeypatch.setattr(U, "logger", U.logger)
from src.config import settings
monkeypatch.setattr(settings, "gitea_url", "http://localhost:3000", raising=False)
monkeypatch.setattr(
settings, "gitea_public_url", "https://git.mva154.duckdns.org", raising=False
)
monkeypatch.setattr(settings, "gitea_owner", "admin", raising=False)
yield
def _wt_with_files(tmp_path, files: dict) -> str:
"""Create a worktree skeleton with given files. `files` maps rel-path -> body."""
base = tmp_path / "wt"
docs = base / "docs" / "work-items" / WID
docs.mkdir(parents=True)
for rel, body in files.items():
p = docs / rel if not rel.startswith("/") else base / rel.lstrip("/")
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(body)
return str(base)
# ---------------------------------------------------------------------------
# TC-01: architect comment
# ---------------------------------------------------------------------------
def test_tc01_architect_comment(tmp_path):
wt = _wt_with_files(tmp_path, {"06-adr/ADR-001-x.md": "x"})
html = U.build_status_comment(
"architect",
repo=REPO, branch=BRANCH, work_item_id=WID,
duration_s=312,
worktree_root=wt,
)
# Header
assert "\U0001f4d0 Architect — " in html, html
assert "архитектурную" in html
assert "См. ADR ниже" in html
# Duration: 312s -> 5m 12s
assert "Длительность: 5m 12s" in html
# ADR link via gitea_public_url
assert ("https://git.mva154.duckdns.org/admin/enduro-trails/src/branch/"
f"{BRANCH}/docs/work-items/{WID}/06-adr") in html
# No Verdict for architect
assert "Verdict" not in html
assert "Status:" not in html
# ---------------------------------------------------------------------------
# TC-02: developer comment with PR + branch
# ---------------------------------------------------------------------------
def test_tc02_developer_comment_links_branch_and_pr():
html = U.build_status_comment(
"developer",
repo=REPO, branch=BRANCH, work_item_id=WID,
pr_number=42, duration_s=600,
)
assert "\U0001f4bb Developer — " in html
assert "разработку" in html
# Both branch and PR links
assert f"https://git.mva154.duckdns.org/admin/{REPO}/src/branch/{BRANCH}" in html
assert f"https://git.mva154.duckdns.org/admin/{REPO}/pulls/42" in html
assert f"PR #42" in html
assert "Длительность: 10m 00s" in html
assert "Verdict" not in html
# ---------------------------------------------------------------------------
# TC-03 / TC-04: reviewer verdict via frontmatter
# ---------------------------------------------------------------------------
def test_tc03_reviewer_verdict_approve(tmp_path):
wt = _wt_with_files(tmp_path, {
"12-review.md": "---\nverdict: APPROVE\n---\nbody...",
})
html = U.build_status_comment(
"reviewer",
repo=REPO, branch=BRANCH, work_item_id=WID,
duration_s=120, worktree_root=wt,
)
assert "\U0001f50e Reviewer — " in html
assert "Verdict: APPROVE" in html
assert "Длительность: 2m 00s" in html
assert "12-review.md" in html
def test_tc04_reviewer_verdict_request_changes(tmp_path):
wt = _wt_with_files(tmp_path, {
"12-review.md": "---\nverdict: REQUEST_CHANGES\n---\nblockers...",
})
html = U.build_status_comment(
"reviewer",
repo=REPO, branch=BRANCH, work_item_id=WID,
duration_s=45, worktree_root=wt,
)
assert "Verdict: REQUEST_CHANGES" in html
assert "Длительность: 45s" in html
# ---------------------------------------------------------------------------
# TC-05: reviewer with NO 12-review.md -> graceful (no Verdict, no Review link)
# but Длительность and header still present.
# ---------------------------------------------------------------------------
def test_tc05_reviewer_missing_artifact_graceful(tmp_path):
wt = _wt_with_files(tmp_path, {}) # empty docs dir
html = U.build_status_comment(
"reviewer",
repo=REPO, branch=BRANCH, work_item_id=WID,
duration_s=30, worktree_root=wt,
)
assert "\U0001f50e Reviewer — " in html
assert "Verdict" not in html
# Link to 12-review.md is dropped (AC-8 graceful).
assert "12-review.md" not in html
# Duration still printed when known.
assert "Длительность: 30s" in html
# ---------------------------------------------------------------------------
# TC-06 / TC-07: tester verdict via frontmatter (verdict OR status)
# ---------------------------------------------------------------------------
def test_tc06_tester_pass(tmp_path):
wt = _wt_with_files(tmp_path, {
"13-test-report.md": "---\nverdict: PASS\n---\n",
})
html = U.build_status_comment(
"tester",
repo=REPO, branch=BRANCH, work_item_id=WID,
duration_s=240, worktree_root=wt,
)
assert "\U0001f9ea Tester — " in html
assert "Verdict: PASS" in html
assert "Длительность: 4m 00s" in html
assert "13-test-report.md" in html
def test_tc07_tester_fail(tmp_path):
wt = _wt_with_files(tmp_path, {
"13-test-report.md": "---\nverdict: FAIL\n---\n",
})
html = U.build_status_comment(
"tester",
repo=REPO, branch=BRANCH, work_item_id=WID,
duration_s=240, worktree_root=wt,
)
assert "Verdict: FAIL" in html
assert "Длительность: 4m 00s" in html
def test_tc07b_tester_falls_back_to_status_key(tmp_path):
# Some testers used `status:` instead of `verdict:` (ET-006 / ET-008 pattern).
wt = _wt_with_files(tmp_path, {
"13-test-report.md": "---\nstatus: PASSED\n---\n",
})
html = U.build_status_comment(
"tester",
repo=REPO, branch=BRANCH, work_item_id=WID,
duration_s=10, worktree_root=wt,
)
assert "Verdict: PASSED" in html
# ---------------------------------------------------------------------------
# TC-08 / TC-09: deployer status via frontmatter
# ---------------------------------------------------------------------------
def test_tc08_deployer_deploy_status_success(tmp_path):
wt = _wt_with_files(tmp_path, {
"14-deploy-log.md": "---\ndeploy_status: SUCCESS\n---\n",
})
html = U.build_status_comment(
"deployer",
repo=REPO, branch=BRANCH, work_item_id=WID,
stage="deploy", duration_s=120, worktree_root=wt,
)
assert "\U0001f680 Deployer — " in html
assert "Status: SUCCESS" in html
assert "Длительность: 2m 00s" in html
assert "14-deploy-log.md" in html
def test_tc09_deployer_staging_status_success(tmp_path):
wt = _wt_with_files(tmp_path, {
"15-staging-log.md": "---\nstaging_status: SUCCESS\n---\n",
})
html = U.build_status_comment(
"deployer",
repo=REPO, branch=BRANCH, work_item_id=WID,
stage="deploy-staging", duration_s=60, worktree_root=wt,
)
assert "Status: SUCCESS" in html
assert "Длительность: 1m 00s" in html
# The staging-stage helper links 15-staging-log.md, not 14-deploy-log.md.
assert "15-staging-log.md" in html
assert "14-deploy-log.md" not in html
def test_deployer_status_failed_drives_status_line(tmp_path):
wt = _wt_with_files(tmp_path, {
"14-deploy-log.md": "---\ndeploy_status: FAILED\n---\n",
})
html = U.build_status_comment(
"deployer",
repo=REPO, branch=BRANCH, work_item_id=WID,
stage="deploy", duration_s=5, worktree_root=wt,
)
assert "Status: FAILED" in html
# ---------------------------------------------------------------------------
# TC-10: gitea_public_url is preferred; falls back to gitea_url when empty.
# ---------------------------------------------------------------------------
def test_tc10_url_fallback_to_gitea_url(monkeypatch):
from src.config import settings
monkeypatch.setattr(settings, "gitea_public_url", "", raising=False)
monkeypatch.setattr(settings, "gitea_url", "http://localhost:3000", raising=False)
html = U.build_status_comment(
"developer",
repo=REPO, branch=BRANCH, work_item_id=WID,
pr_number=7, duration_s=15,
)
assert "http://localhost:3000/admin/enduro-trails/pulls/7" in html
# And the public URL is not there because it was empty.
assert "git.mva154.duckdns.org" not in html
# ---------------------------------------------------------------------------
# TC-12: frontmatter parser is graceful — missing file / empty / bad YAML -> None
# (the comment still publishes the header + duration, just no Verdict / Status).
# ---------------------------------------------------------------------------
def test_tc12_frontmatter_missing_file_no_crash(tmp_path):
from src.frontmatter import read_frontmatter_value
assert read_frontmatter_value(str(tmp_path / "nope.md"), "verdict") is None
def test_tc12_frontmatter_empty_no_crash(tmp_path):
p = tmp_path / "empty.md"
p.write_text("")
from src.frontmatter import read_frontmatter_value
assert read_frontmatter_value(str(p), "verdict") is None
def test_tc12_frontmatter_bad_yaml_no_crash(tmp_path):
p = tmp_path / "bad.md"
p.write_text("---\nverdict: [unterminated\n---\nbody")
from src.frontmatter import read_frontmatter_value
assert read_frontmatter_value(str(p), "verdict") is None
def test_tc12_frontmatter_missing_key_returns_none(tmp_path):
p = tmp_path / "ok.md"
p.write_text("---\nother: value\n---\nbody")
from src.frontmatter import read_frontmatter_value
assert read_frontmatter_value(str(p), "verdict") is None
# ---------------------------------------------------------------------------
# TC-23: duration_s=None and no task_id -> the Длительность line is OMITTED.
# Header / description / artifact links remain.
# ---------------------------------------------------------------------------
def test_tc23_no_duration_no_line(tmp_path):
wt = _wt_with_files(tmp_path, {"06-adr/ADR-001-x.md": "x"})
html_none = U.build_status_comment(
"architect",
repo=REPO, branch=BRANCH, work_item_id=WID,
duration_s=None, worktree_root=wt,
)
html_default = U.build_status_comment(
"architect",
repo=REPO, branch=BRANCH, work_item_id=WID,
worktree_root=wt,
)
for html in (html_none, html_default):
assert "Длительность" not in html
# But the header, description and ADR link are still there.
assert "\U0001f4d0 Architect — " in html
assert "архитектурную" in html
assert "06-adr" in html
# ---------------------------------------------------------------------------
# Extra: usage tail is rendered as <sub> when non-zero, suppressed otherwise.
# (Backs up ADR-001 §3 and keeps the old usage_comment test contract.)
# ---------------------------------------------------------------------------
def test_usage_tail_rendered_when_non_zero():
html = U.build_status_comment(
"developer",
repo=REPO, branch=BRANCH, work_item_id=WID,
usage={"input_tokens": 45231, "output_tokens": 12100, "cost_usd": 0.21},
)
assert "<sub>" in html and "</sub>" in html
assert "45.2k in" in html
assert "12.1k out" in html
assert "$0.21" in html
def test_usage_tail_suppressed_when_all_zero():
html = U.build_status_comment("developer", repo=REPO, branch=BRANCH)
assert "<sub>" not in html
# ---------------------------------------------------------------------------
# AC-1 / AC-5 literal strings — fixed wording per role.
# ---------------------------------------------------------------------------
def test_ac1_architect_header_literal():
html = U.build_status_comment("architect", repo=REPO, branch=BRANCH,
work_item_id=WID, duration_s=10)
assert "\U0001f4d0 Architect — " in html
def test_ac5_deployer_deploy_description():
html = U.build_status_comment(
"deployer", repo=REPO, branch=BRANCH, work_item_id=WID, stage="deploy",
)
assert "прод-деплой" in html
def test_ac5_deployer_staging_description():
html = U.build_status_comment(
"deployer", repo=REPO, branch=BRANCH, work_item_id=WID, stage="deploy-staging",
)
assert "staging-деплой" in html