Compare commits
31 Commits
feat/ORCH-
...
docs/lesso
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58116f93bd | ||
| 941eec248e | |||
| b061354a8f | |||
| 5d04de9eb6 | |||
| edff0484c9 | |||
| 2f396452e8 | |||
| 185eb3f6cf | |||
| 58fc0a8b94 | |||
| c1abfb7436 | |||
| 51a76e8169 | |||
| 75fb4069a4 | |||
| c3879f2b80 | |||
| 974d4f94db | |||
| 982698c4e3 | |||
|
|
0eff781d13 | ||
| b9c61fc1f1 | |||
|
|
53c76cb539 | ||
| 26c6f2676f | |||
|
|
43ef160f40 | ||
| 9c28431167 | |||
|
|
d615747d53 | ||
| c91eb7f82b | |||
| e62d51aa77 | |||
| 0e999d289d | |||
| 950a86e4d8 | |||
| 6509891f74 | |||
| 69a4aaab99 | |||
| c9b1195c0b | |||
| 08528b655e | |||
| 7f31d62a4d | |||
| 401bf66fe0 |
@@ -1,4 +1,8 @@
|
||||
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=
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Поллинг с ретраем в quality-gate `check_ci_green`** (ORCH-045): гейт CI превращён из single-shot в polling, чтобы устранить race condition — раньше один опрос combined commit-status сразу после пуша developer-а ловил транзиентный `pending` (типично 1-3с, реальный кейс ORCH-017: опрос 17:58:54 → pending, CI дозеленел 17:58:55) и задача застревала насмерть без повторного опроса. Теперь: `success` → пропуск сразу; `failure`/`error` → провал сразу (терминально, ретрай бессмыслен); `pending`/unknown → `time.sleep` и повторный опрос до `ci_poll_max_attempts` раз; истечение попыток → явный `(False, "CI still pending after <T>s")` (тупик больше не молчаливый); 404 → как раньше; транзиентная `httpx.HTTPError` на попытке логируется и ретраится в рамках бюджета. Параметры — новые настройки `ORCH_CI_POLL_MAX_ATTEMPTS` (12) и `ORCH_CI_POLL_INTERVAL_S` (10) в `src/config.py` (~2 мин ожидания pending). Сигнатура `check_ci_green(repo, branch)` и реестр `QG_CHECKS` не менялись; `check_tests_passed` не затронут. ADR `docs/architecture/adr/adr-0004-ci-poll-retry.md`. Тесты: `tests/test_qg.py::TestCheckCIGreen`.
|
||||
- **Прямые ссылки на BRD и Plane-таску в Telegram-уведомлении об апруве** (ORCH-017): пингующее сообщение `notify_approve_requested` теперь встраивает две HTML-`<a>`-ссылки — на `docs/work-items/<WI>/01-brd.md` (Gitea branch-view: `gitea_public_url`→`gitea_url`) и на issue в Plane (`{web_base}/{workspace}/projects/{project_id}/issues/{plane_issue_id}/`). Новая настройка `ORCH_PLANE_WEB_URL` (внешний браузерный web-URL Plane; фолбэк на `plane_api_url`). **Loopback-guard:** если итоговый Plane web-base указывает на localhost/127.0.0.1/0.0.0.0/::1 или пуст — Plane-ссылка опускается (не выпускаем битый localhost-URL). Graceful degradation: каждая ссылка строится независимо и опускается при нехватке данных, сообщение и призыв «Переведите задачу в статус Approved …» сохраняются всегда; ровно одно пингующее сообщение, разделяемая `send_telegram` не тронута. Динамические подписи экранируются `html.escape`, `parse_mode=HTML` сохранён. ADR `docs/work-items/ORCH-017/06-adr/ADR-001-telegram-approve-links.md`. Тесты: `test_notify_approve_links.py`, `test_analysis_approve_flow_links.py`.
|
||||
- **Конфигурируемые модель 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-гейт).
|
||||
@@ -19,6 +22,7 @@
|
||||
- Цепочка стадий: `... testing → deploy-staging → deploy → done` (была без `deploy-staging`).
|
||||
|
||||
### Fixed
|
||||
- **Testing-гейт `check_tests_passed` читает `result:` наравне с `verdict:`/`status:`** (ORCH-047): парсер `_parse_tests_verdict` (`src/qg/checks.py`) теперь принимает три равноправных машиночитаемых поля frontmatter `13-test-report.md` — `result:` (канон промпта тестера `.openclaw/agents/tester.md`, `result: PASS|FAIL`), плюс легаси `verdict:` и `status:` (enduro-trails ET-001..ET-014); достаточно любого одного непустого. Устраняет рассинхрон контракта: тестер честно эмитил `result: PASS` без `verdict:`/`status:`, парсер попадал в ветку «нет машинного вердикта» → откат `testing → development` в петлю до исчерпания `MAX_DEVELOPER_RETRIES` (наблюдалось на ORCH-17; ORCH-016 прошёл лишь из-за избыточного дублирования полей). Семантика приоритетов сохранена и распространена на все три поля через объединённую строку: negative-токен в любом поле авторитетен (перебивает positive), наборы токенов заморожены (обратная совместимость). Сигнатура гейта, имя и реестр `QG_CHECKS` не менялись. ADR `docs/work-items/ORCH-047/06-adr/ADR-001-result-field-in-tests-gate.md`. Тесты: `tests/test_qg.py::TestCheckTestsPassed`.
|
||||
- БАГ-8: провал deploy/deploy-staging → корректный откат на `development`.
|
||||
- Изоляция тестов от живого Plane API (PR #27): autouse-фикстура сброса settings.
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ created → analysis → architecture → development → review → testing →
|
||||
```
|
||||
|
||||
- **Длительность** считается 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).
|
||||
- **Vердикт-парсер** — `src/frontmatter.read_frontmatter_value(...)` (defensive, никогда не raise). Машинные ключи: reviewer → `verdict:` (12-review.md); **testing-гейт `check_tests_passed` (13-test-report.md) → любое из трёх равноправных: `result:` (канон промпта тестера), `verdict:`, `status:`** (ORCH-047, ADR-001); deployer → `deploy_status:` (14-deploy-log.md), `staging_status:` (15-staging-log.md). Negative-токен в любом поле авторитетен (перебивает positive).
|
||||
- Формат коммента **не** меняет реестр гейтов и стадий; коммент — отображение, не управление.
|
||||
|
||||
## База данных (SQLite)
|
||||
|
||||
@@ -8,6 +8,7 @@ Per-work-item решения живут в `docs/work-items/<id>/06-adr/ADR-NNN-
|
||||
| adr-0001 | Реестр проектов (multi-repo) | accepted | 2026-06-02 | ORCH-6 |
|
||||
| adr-0002 | Очередь задач вместо in-process потоков | accepted | 2026-06-03 | ORCH-1 |
|
||||
| adr-0003 | Условный staging-гейт перед прод-деплоем | accepted | 2026-06-05 | ORCH-35 |
|
||||
| adr-0004 | Поллинг с ретраем в check_ci_green (фикс CI-race) | accepted | 2026-06-05 | ORCH-045 |
|
||||
|
||||
## Формат
|
||||
**Контекст → Решение → Альтернативы → Последствия → Связи.** Статус: proposed / accepted / superseded.
|
||||
|
||||
45
docs/architecture/adr/adr-0004-ci-poll-retry.md
Normal file
45
docs/architecture/adr/adr-0004-ci-poll-retry.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# adr-0004: Поллинг с ретраем в quality-gate check_ci_green (фикс CI-race)
|
||||
|
||||
- **Статус:** accepted
|
||||
- **Дата:** 2026-06-05
|
||||
- **Задача:** ORCH-045
|
||||
|
||||
## Контекст
|
||||
Quality-gate `check_ci_green(repo, branch)` (`src/qg/checks.py`) проверяет combined commit-status ветки через Gitea API сразу после того, как developer-агент запушил код. Реализация была **single-shot**: один `GET /repos/{owner}/{repo}/commits/{branch}/status`, чтение `data["state"]` — `success` → пропуск, иначе → сразу `False`.
|
||||
|
||||
Это создавало race condition. Gitea-CI после пуша 1–3 секунды держит combined state `pending`, пока не отработают чек-раннеры. Если гейт опрашивал статус в этом окне, он получал `pending` и возвращал `False` **ровно один раз** — повторного опроса не было. Combined state затем дозеленевал до `success`, но гейт уже промахнулся, и задача застревала насмерть без видимой причины.
|
||||
|
||||
Реальный инцидент **ORCH-017**: гейт опросил статус в 17:58:54 → `pending`; CI дозеленел в 17:58:55. Задача встала в тупик (см. `docs/history` / lessons ORCH-017).
|
||||
|
||||
## Решение
|
||||
`check_ci_green` превращён из single-shot в **polling с ретраем**:
|
||||
|
||||
- `state == "success"` → `(True, "CI green")` немедленно.
|
||||
- `state in ("failure", "error")` → `(False, "CI state: <state>")` немедленно — CI красный, ретрай бессмыслен (терминальное состояние).
|
||||
- `state == "pending"` (или `unknown` / иное не-терминальное) → `time.sleep(interval)` и опрос снова, до `N` попыток.
|
||||
- После исчерпания всех попыток при всё ещё `pending` → `(False, "CI still pending after <T>s")` — **явный** провал с причиной, чтобы оператор видел тупик, а не молчаливый стол.
|
||||
- `404` → `(False, "Branch ... not found or no status")` — как раньше.
|
||||
- Транзиентная `httpx.HTTPError` на отдельной попытке — **не падаем сразу**: логируем и пробуем ещё в рамках лимита попыток; если все попытки — сетевая ошибка → `(False, "API error: <e>")`.
|
||||
|
||||
Параметры вынесены в `src/config.py` (pydantic-settings, env-prefix `ORCH_`, единый стиль с остальными настройками):
|
||||
- `ci_poll_max_attempts` (env `ORCH_CI_POLL_MAX_ATTEMPTS`, дефолт **12**)
|
||||
- `ci_poll_interval_s` (env `ORCH_CI_POLL_INTERVAL_S`, дефолт **10**)
|
||||
|
||||
Итого по умолчанию гейт ждёт `pending` до ~2 минут (12 × 10s) перед тем как явно провалиться. Каждая не-финальная попытка логируется через существующий `logger` (`check_ci_green: attempt i/N, state=..., retrying in Ns`). `timeout=10` на каждый отдельный запрос сохранён.
|
||||
|
||||
Сигнатура `check_ci_green(repo, branch) -> tuple[bool, str]` **не менялась** — её зовёт stage_engine и реестр гейтов `QG_CHECKS`.
|
||||
|
||||
## Альтернативы
|
||||
- **Оставить single-shot, опрашивать гейт повторно снаружи (на уровне stage_engine/воркера).** Отклонено: размазывает логику CI-ожидания по слоям, дублирует таймауты; гейт — естественное место знания о combined-status.
|
||||
- **Webhook от Gitea на завершение CI вместо поллинга.** Отложено: требует надёжной доставки/дедупликации вебхуков именно по CI-статусу и переписывания триггера стадии; поллинг — минимальный, локализованный фикс race-а здесь и сейчас.
|
||||
- **Бесконечный ретрай до зелёного.** Отклонено: задача могла бы висеть вечно при реально зависшем CI; ограниченный бюджет + явный `False` с причиной даёт оператору сигнал.
|
||||
|
||||
## Последствия
|
||||
- CI-race ORCH-017 закрыт: транзиентный `pending` переживается ретраем, гейт не промахивается.
|
||||
- `check_ci_green` теперь **блокирующий** до ~`max_attempts × interval` секунд при затяжном `pending` (по умолчанию ~2 мин). Это осознанный trade-off; для красного CI и success — выход немедленный, без задержки.
|
||||
- Тупик больше не молчаливый: истечение попыток → `(False, "CI still pending after <T>s")`, причина видна.
|
||||
- Бюджет/интервал настраиваемы через env без правки кода.
|
||||
- `check_tests_passed` / `_parse_tests_verdict` (ORCH-47) **не затронуты**.
|
||||
|
||||
## Связи
|
||||
ORCH-017 (инцидент-первоисточник: deadlock shared-gate из-за CI-race), реестр гейтов `QG_CHECKS` (`check_ci_green`), стадия `development`. Тесты: `tests/test_qg.py::TestCheckCIGreen`.
|
||||
@@ -326,6 +326,10 @@ 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.
|
||||
|
||||
|
||||
128
docs/history/LESSONS_2026-06-05.md
Normal file
128
docs/history/LESSONS_2026-06-05.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Lessons Learned — 2026-06-05 (вечер): ORCH-17/45/47 + деплой прода
|
||||
|
||||
## Итог дня
|
||||
Закрыты три задачи (ORCH-17, ORCH-45, ORCH-47), два прод-гейта стали умнее, заведено
|
||||
4 системных задачи в бэклог (ORCH-44/46/48 + B6). Главный сквозной урок: **конвейер не мог
|
||||
провести эти задачи автономно из-за дыр в самом конвейере** — потребовались ручные merge и
|
||||
ребилды прода. Корни задокументированы, чинятся отдельными задачами.
|
||||
|
||||
---
|
||||
|
||||
## 1. ORCH-17 — approve-ping links (закрыта вручную)
|
||||
Подробный разбор: `docs/history/LESSONS_ORCH-017.md`. Кратко: косметика (2 ссылки)
|
||||
застряла 5 раз, объективный дедлок shared-гейта, ручной merge PR #37 (`26c6f267`).
|
||||
|
||||
---
|
||||
|
||||
## 2. ORCH-45 — CI-гонка в `check_ci_green` (исправлена, в проде)
|
||||
|
||||
### Проблема
|
||||
`check_ci_green` делал **один** запрос статуса CI сразу после developer. Если CI ещё
|
||||
`pending` 1-3 секунды (реальный кейс: опрос 17:58:54 → pending, CI позеленел 17:58:55) —
|
||||
гейт возвращал False **один раз** и задача застревала насмерть с зелёным CI.
|
||||
|
||||
### Решение (PR #39, merge `982698c4`)
|
||||
Поллинг с ретраем: `success`/`failure` — терминальны (сразу), `pending` → ждать
|
||||
`CI_POLL_INTERVAL_S`(10с) до `CI_POLL_MAX_ATTEMPTS`(12) раз, истёк лимит → явный
|
||||
`False` с причиной "CI still pending after Ns" (не виснет молча). Параметры в `config.py`
|
||||
как env `ORCH_CI_POLL_*`. ADR-0004. +5 тестов (мок httpx + time.sleep).
|
||||
|
||||
---
|
||||
|
||||
## 3. ORCH-47 — тестер-гейт игнорил `result:` (исправлен, в проде)
|
||||
|
||||
### Проблема (уловка-22)
|
||||
`check_tests_passed`/`_parse_tests_verdict` читал только `verdict:`/`status:` из frontmatter
|
||||
`13-test-report.md`, но промпт tester-агента велит писать `result: PASS|FAIL`. Честный тестер
|
||||
(`result: PASS`, без `verdict:`) → гейт «No machine-readable verdict» → ложный FAIL → петля
|
||||
dev↔review↔tester → Blocked. **И сама ORCH-47 (которая это чинит) попала в тот же капкан:**
|
||||
в проде крутился старый гейт → не понимал её собственный `result: PASS` → 3 круга петли.
|
||||
Змея кусает хвост: чтобы пройти гейт автономно, фикс уже должен быть в проде.
|
||||
|
||||
### Решение (PR #40, merge `5d04de9e`)
|
||||
`result:` добавлен как равноправное поле наряду с `verdict:`/`status:`. Любое одно непустое
|
||||
поле достаточно. Negative-токен (BLOCKED/FAILED) в ЛЮБОМ поле авторитетен (ET-013 кейс
|
||||
сохранён). Token sets заморожены для обратной совместимости. ADR-001. +6 тестов (68 passed).
|
||||
После деплоя ручной `advance_stage` пнул застрявшую task → гейт принял `result: PASS` →
|
||||
прошёл testing. Петля исчезла навсегда.
|
||||
|
||||
### Остаточная находка → B6 / ORCH-48
|
||||
На staging деплоер дал 9/10 PASS, завалил **B6 Registry isolation**: staging-реестр видит
|
||||
боевые ET+ORCH вместо одного sandbox (нарушает «staging — только sandbox»). Деплоер честно
|
||||
поставил FAILED и НЕ стал натягивать зелёнку (вне мандата) → откат by design. К фиксу гейта
|
||||
отношения не имеет (E2E против sandbox прошёл). Заведена ORCH-48.
|
||||
|
||||
---
|
||||
|
||||
## 4. ДЕПЛОЙ ПРОДА — как правильно (важная операционная памятка)
|
||||
|
||||
### `/app` запечён в образ, НЕ volume
|
||||
`docker-compose.yml`: `build: .` + `COPY src/ ./src/`. Поэтому `git pull` + рестарт с
|
||||
`--no-build` **НЕ довозит код** — нужен `docker compose build orchestrator`. Деплой-хук
|
||||
(`scripts/orchestrator-deploy-hook.sh`) по дефолту целит в **staging** (by design) — для
|
||||
прода нужны env `TARGET_SERVICE=orchestrator TARGET_PORT=8500 COMPOSE_PROFILE=''`.
|
||||
|
||||
### Порты/профили
|
||||
- prod orchestrator = порт **8500** (`/health` → `{"status":"ok"}`), `network_mode: host`,
|
||||
профиль prod = пустой (стартует обычным `docker compose up -d orchestrator`).
|
||||
- staging = порт **8501**, профиль `staging` (стартует только `--profile staging`).
|
||||
|
||||
### Рабочая последовательность деплоя (проверена дважды 05.06)
|
||||
1. `sudo chown -R slin:slin /home/slin/repos/orchestrator` (см. грабля ниже).
|
||||
2. `git checkout main && git reset --hard origin/main && git clean -fd -e '*.bak*' -e '.deploy-prev-image-prod'`.
|
||||
3. `docker compose build orchestrator`.
|
||||
4. `docker compose up -d orchestrator` + health-loop на :8500.
|
||||
5. **Проверка claude-auth** (ребилд её ломает — см. ниже).
|
||||
6. Проверка что новый код активен в `/app` (grep маркера правки).
|
||||
|
||||
### ⚠️ ГРАБЛЯ: хост-репо рассинхронизирован с git (агенты пишут под root)
|
||||
Хост-репо `/home/slin/repos/orchestrator` оказывался на feature-ветке (не main), а рабочая
|
||||
копия засеяна untracked+modified файлами, созданными агентами **под uid=0 (root-owned)** прямо
|
||||
в репо. → `git pull --ff-only` падал `Permission denied` / `would be overwritten`, обычный
|
||||
`rm` под slin не мог снести root-файлы. **Лечение:** `sudo chown -R slin:slin <repo>` →
|
||||
проверить что modified=совпадает-с-main и untracked=уже-в-main (дубликаты, не теряем) →
|
||||
`git reset --hard origin/main` + `git clean`. **Хук это НЕ разруливает** — сверять состояние
|
||||
хост-репо перед каждым деплоем.
|
||||
|
||||
### ⚠️ ГРАБЛЯ: ребилд ломает claude-auth (проверять ВСЕГДА)
|
||||
Пересоздание контейнера может root-овнить `/home/slin/.claude/.credentials.json` и сделать
|
||||
`/root/.claude` пустышкой → агенты падают `Not logged in`. Защита — монтирование creds в
|
||||
compose (`/home/slin/.claude` + `.claude.json`), launcher форсит `HOME=/home/slin`.
|
||||
**После каждого ребилда боевая проверка:**
|
||||
`docker exec orchestrator bash -c 'cd /tmp && HOME=/home/slin /opt/claude-code/bin/claude.exe --print "ОК"'`
|
||||
(timeout 90с). 05.06 auth пережил оба ребилда — защита держит.
|
||||
|
||||
---
|
||||
|
||||
## 5. ЗАПУСК конвейера и Gitea API
|
||||
|
||||
### Старт конвейера = Plane Backlog → In Progress
|
||||
Конвейер стартует штатно переводом задачи в Plane из Backlog в **In Progress** (код:
|
||||
`webhooks/plane.py handle_status_start` — «pipeline is started when Slava moves the issue
|
||||
into In Progress»). Webhook создаёт task-row, заводит ветку, запускает analyst. Никаких
|
||||
ручных вставок в БД.
|
||||
|
||||
### QG-0: лимит заголовка 80 символов
|
||||
При старте задача с заголовком >80 символов заворачивается на QG-0 («Title слишком длинный»)
|
||||
и уходит в Blocked. Чинить — укоротить `name` (суть в заголовок, детали в description),
|
||||
вернуть в Backlog, снова In Progress.
|
||||
|
||||
### Gitea API грабли
|
||||
- **merge/create PR** требуют заголовок `Authorization: token <ORCH_GITEA_TOKEN>` (форма
|
||||
с префиксом `token `), иначе 401 "token is required".
|
||||
- **heredoc через ssh+docker exec глотает вывод** python-скрипта. Надёжный путь: написать
|
||||
`.py` локально → `base64 -w0` → `ssh "echo <b64> | base64 -d > /tmp/x.py"` → `docker cp`
|
||||
→ `docker exec python3 /tmp/x.py`. Это же обходит экранирование кириллицы/скобок.
|
||||
|
||||
---
|
||||
|
||||
## Состояние прод-гейтов после 05.06
|
||||
- ✅ `check_ci_green` — поллинг с ретраем (ORCH-45)
|
||||
- ✅ `check_tests_passed` — читает `result:`/`verdict:`/`status:` (ORCH-47)
|
||||
|
||||
## Бэклог (high) после дня
|
||||
- **ORCH-44** — надёжность запуска агента (preflight слеп к auth; `--effort` гасит вывод;
|
||||
пустой run-лог → должен быть failed).
|
||||
- **ORCH-46** — «испорченный телефон»: орк не передаёт деву ТЕКСТ замечаний reviewer/tester
|
||||
(только ссылку на файл), противоречивые сигналы tester↔reviewer, нет памяти между кругами.
|
||||
- **ORCH-48 / B6** — staging registry isolation (staging видит прод-проекты вместо sandbox).
|
||||
103
docs/history/LESSONS_ORCH-017.md
Normal file
103
docs/history/LESSONS_ORCH-017.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# Lessons Learned — ORCH-017 (Telegram approve-ping links)
|
||||
|
||||
## Дата: 2026-06-05
|
||||
## Задача: ORCH-017 — Прямые ссылки на BRD и Plane-таску в Telegram-уведомлении об апруве BRD
|
||||
## Итог: смержено **вручную** (PR #37, merge `26c6f267`) после ~5 застреваний конвейера
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
Косметическая задача (две HTML-ссылки в уведомлении) **5 раз застряла** в конвейере и каждый
|
||||
раз требовала ручного пинка. Корень — **не баг задачи, а дыры автономности конвейера**. Код был
|
||||
готов и зелёный (434 теста), но пайплайн не мог довести его до merge сам. В итоге — ручной merge
|
||||
Owner-ом; четыре системные дыры заведены в бэклог (ORCH-44/45/46/47).
|
||||
|
||||
---
|
||||
|
||||
## Хронология застреваний
|
||||
|
||||
1. **Auth claude после rebuild** — агенты падали `Not logged in` (root-owned creds + `/root/.claude`
|
||||
пустышка). См. отдельный разбор в memory/INCIDENT по auth. → починено + защищено монтированием.
|
||||
2. **`check_ci_green` race** — гейт опросил CI **один раз** в `17:58:54` → `pending`; CI дозеленел в
|
||||
`17:58:55` (промах на 1 секунду). Повторного опроса нет → задача висит насмерть с зелёным CI.
|
||||
3. **Петля dev↔review↔testing → `max retries reached`** (MAX_DEVELOPER_RETRIES=3).
|
||||
4. **Откат неполный** — убрали код shared-гейта, но оставили 2 doc-строки про него → рассинхрон
|
||||
код↔доки → reviewer снова REQUEST_CHANGES.
|
||||
5. **Объективный дедлок** (см. ниже) → ручной merge.
|
||||
|
||||
---
|
||||
|
||||
## Корневые проблемы (→ бэклог)
|
||||
|
||||
### P1. `check_ci_green` промахивается на гонке CI (→ ORCH-45)
|
||||
Гейт читает статус CI **ровно один раз** сразу после developer. Если CI ещё `pending` — задача
|
||||
застревает молча, без повторного опроса. Нужен polling с ретраем: `pending` → ждать N×15с,
|
||||
`success` → advance, `failure` → rollback, вечный `pending` → уведомить (не застревать молча).
|
||||
|
||||
### P2. Developer не понимает замечаний reviewer/tester — "испорченный телефон" (→ ORCH-46)
|
||||
**Это прямой удар по автономности.** Три причины, почему dev повторял одну и ту же ошибку:
|
||||
- **Испорченный телефон.** При REQUEST_CHANGES `stage_engine.py:~421` шлёт developer-у только
|
||||
`"Fix findings in docs/work-items/<WI>/12-review.md"` — **без текста претензий**, лишь ссылку на
|
||||
файл. Ключевую governance-мысль легко проскочить. → Вклеивать ТЕКСТ findings прямо в task_desc.
|
||||
- **Противоречивые сигналы.** После tester прилетает `"Tests FAILED. Fix failures"` (толкает чинить
|
||||
связанное с тестами → dev лез в test-gate). После reviewer — `"не трогай gate"`. Два
|
||||
противоположных приказа. → Склеивать замечания tester+reviewer в одно непротиворечивое ТЗ.
|
||||
- **Нет памяти между кругами.** Каждый запуск developer — новый чистый агент, не помнит прошлых
|
||||
заворотов. Видит "тесты падают" → снова лезет в gate. → Передавать историю прошлых REQUEST_CHANGES/
|
||||
FAIL ("на чём уже погорел, чего НЕ делать"). Можно: ранняя эскалация к Owner при повторе.
|
||||
|
||||
### P3. `check_tests_passed` игнорирует поле `result:` (→ ORCH-47)
|
||||
`_parse_tests_verdict` (`src/qg/checks.py`) читал только `verdict:`/`status:` из frontmatter
|
||||
`13-test-report.md`. НО промпт tester-агента (`.openclaw/agents/tester*`) предписывает писать
|
||||
`result: PASS | FAIL`. Честный тестер (отчёт ORCH-017: `result: PASS`, без `verdict:`/`status:`)
|
||||
проваливал гейт ложным «Tests FAILED» → откат на development. ORCH-016 проходил лишь потому, что
|
||||
дублировал `verdict:` И `result:`. → Гейт должен читать `result:` как первоклассное машинное поле.
|
||||
**ВАЖНО:** это shared-гейт (влияет на ВСЕ проекты общего прода) → требует отдельного ADR
|
||||
(CLAUDE.md правило 2), потому вынесено в свой work item, не в ORCH-017.
|
||||
|
||||
### P4. Preflight слеп к auth и битым флагам (→ ORCH-44)
|
||||
`claude --version` отвечает даже без логина → preflight=ok, а реальный запуск падает `Not logged in`.
|
||||
Плюс `--effort` с CLI 2.1.142 + `--print`/`--output-format json` гасит вывод. Нужны: дешёвая
|
||||
проверка auth без токенов (права+дата истечения OAuth в `.credentials.json`), фикс effort,
|
||||
«пустой лог + job running + процесс мёртв → failed».
|
||||
|
||||
---
|
||||
|
||||
## Главный урок: объективный дедлок shared-инфры
|
||||
|
||||
ORCH-017 попала в **неразрешимый автономно дедлок** из-за того, что тест-отчёт уже написан под
|
||||
новый контракт (`result: PASS`):
|
||||
- **С фиксом гейта в ветке** → reviewer заворачивает (governance: shared-инфра без ADR). ❌
|
||||
- **Без фикса гейта** → `check_tests_passed` не видит `result:` → ложный FAIL → откат. ❌
|
||||
|
||||
**Вывод:** изменение shared quality-gate нельзя протаскивать внутри прикладной задачи. Оно создаёт
|
||||
циклическую зависимость (артефакты задачи зависят от изменённого гейта, а гейт нельзя менять без
|
||||
отдельного ADR). Менять shared-гейты — только отдельным work item со своим ADR. Если артефакты уже
|
||||
написаны под новый контракт — задача физически не пройдёт, пока не приедет фикс гейта.
|
||||
|
||||
---
|
||||
|
||||
## Урок про роль ассистента/оператора
|
||||
|
||||
Когда оператор **раз за разом пинает гейты и чистит за dev вручную** — это сигнал «конвейер не тянет
|
||||
автономно». Честнее предложить Owner-у ручной merge/эскалацию, чем гонять карусель кругов и доказывать
|
||||
конвейеру то, что уже готово (код зелёный, reviewer: «технически корректно», претензии процедурные).
|
||||
|
||||
---
|
||||
|
||||
## Урок про откат
|
||||
|
||||
При откате **кода** обязательно откатывать и **доки/CHANGELOG**, иначе возникает обратный
|
||||
рассинхрон код↔доки (доки описывают фичу, которой в коде уже нет) → reviewer заворачивает. Откат —
|
||||
это код + доки + changelog + (при необходимости) тест-отчёт одним согласованным движением.
|
||||
|
||||
---
|
||||
|
||||
## Что сработало хорошо
|
||||
|
||||
- **Reviewer ловит governance-нарушения** — корректно завернул протаскивание shared-гейта в
|
||||
прикладную задачу. Процедурно прав, даже когда код технически верный.
|
||||
- **Безопасный ручной пинок гейта** через `stage_engine.advance_stage(...)` — без ребилда/мержа,
|
||||
перевызывает QG внутри процесса орка.
|
||||
- **Ручной merge как осознанный выход** из дедлока (с явным ОК Owner), а не бесконечные круги.
|
||||
@@ -42,12 +42,18 @@
|
||||
| Переменная | Назначение |
|
||||
|-----------|-----------|
|
||||
| `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`.
|
||||
@@ -55,6 +61,26 @@
|
||||
## Реестр проектов (`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).
|
||||
|
||||
7
docs/work-items/ORCH-017/00-business-request.md
Normal file
7
docs/work-items/ORCH-017/00-business-request.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Business Request: Прямые ссылки на BRD и Plane-таску в Telegram-уведомлении об апруве
|
||||
|
||||
Work Item ID: ORCH-017
|
||||
|
||||
## Description
|
||||
|
||||
TBD
|
||||
91
docs/work-items/ORCH-017/01-brd.md
Normal file
91
docs/work-items/ORCH-017/01-brd.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# 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 вручную и не ища комментарий.
|
||||
87
docs/work-items/ORCH-017/02-trz.md
Normal file
87
docs/work-items/ORCH-017/02-trz.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# 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)` (≈ строки 547–566) — единственная точка отправки пингующего уведомления об апруве 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>`), строки ≈483–503 — переиспользовать тот же формат. |
|
||||
| `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) перед прод-деплоем орка.
|
||||
64
docs/work-items/ORCH-017/03-acceptance-criteria.md
Normal file
64
docs/work-items/ORCH-017/03-acceptance-criteria.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# 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 проверяются для каждой такой точки.
|
||||
99
docs/work-items/ORCH-017/04-test-plan.yaml
Normal file
99
docs/work-items/ORCH-017/04-test-plan.yaml
Normal file
@@ -0,0 +1,99 @@
|
||||
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]
|
||||
@@ -0,0 +1,117 @@
|
||||
# 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`).
|
||||
Это конфиг-параметр, а не блокер архитектуры.
|
||||
38
docs/work-items/ORCH-017/07-infra-requirements.md
Normal file
38
docs/work-items/ORCH-017/07-infra-requirements.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# 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).
|
||||
19
docs/work-items/ORCH-017/10-tech-risks.md
Normal file
19
docs/work-items/ORCH-017/10-tech-risks.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# 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 до прод-деплоя.
|
||||
83
docs/work-items/ORCH-017/12-review.md
Normal file
83
docs/work-items/ORCH-017/12-review.md
Normal file
@@ -0,0 +1,83 @@
|
||||
---
|
||||
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.
|
||||
91
docs/work-items/ORCH-017/13-test-report.md
Normal file
91
docs/work-items/ORCH-017/13-test-report.md
Normal file
@@ -0,0 +1,91 @@
|
||||
---
|
||||
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**.
|
||||
7
docs/work-items/ORCH-047/00-business-request.md
Normal file
7
docs/work-items/ORCH-047/00-business-request.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Business Request: check_tests_passed: gate must read result: field from test report
|
||||
|
||||
Work Item ID: ORCH-047
|
||||
|
||||
## Description
|
||||
|
||||
TBD
|
||||
57
docs/work-items/ORCH-047/01-brd.md
Normal file
57
docs/work-items/ORCH-047/01-brd.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# BRD — ORCH-047: check_tests_passed должен читать поле `result:` из тест-отчёта
|
||||
|
||||
## 1. Контекст и проблема
|
||||
|
||||
Quality Gate `check_tests_passed` (`src/qg/checks.py`, функция-парсер `_parse_tests_verdict`) гейтит переход `testing → deploy-staging`. Он читает машиночитаемый вердикт из YAML-frontmatter артефакта `13-test-report.md`.
|
||||
|
||||
**Дефект (обнаружен дев-агентом в ходе ORCH-17, подтверждён 05.06.2026):**
|
||||
парсер читает ТОЛЬКО поля `verdict:` и `status:`. Однако промпт тестер-агента (`.openclaw/agents/tester.md`, строки 51–56 и 78–80) предписывает эмитить машиночитаемое поле **`result: PASS|FAIL`** — и НЕ упоминает ни `verdict:`, ни `status:`.
|
||||
|
||||
В результате тестер, честно следующий своей инструкции (реальный отчёт ORCH-017: `result: PASS`, без `verdict:`/`status:`), упирается в ветку «ни verdict, ни status не заданы» → гейт возвращает `False` с причиной *"No machine-readable verdict/status in test report frontmatter"* → задача откатывается `testing → development`.
|
||||
|
||||
**Последствие:** ЛЮБАЯ задача, где тестер пишет `result: PASS` (то есть строго по своей инструкции), застревает в бесконечной петле `testing ↔ development` до исчерпания `MAX_DEVELOPER_RETRIES`. Именно это крутило ORCH-17. ORCH-016 прошёл раньше лишь потому, что его отчёт избыточно нёс И `verdict:`, И `result:`.
|
||||
|
||||
**Корень:** рассинхрон контракта. Гейт (потребитель) и промпт тестера (производитель) описывают разные имена машиночитаемого поля.
|
||||
|
||||
## 2. Бизнес-цель
|
||||
|
||||
Привести контракт гейта `check_tests_passed` в соответствие с тем, что тестер-агенту реально велено эмитить, чтобы корректные тест-отчёты (`result: PASS`) проходили гейт, а отрицательные (`result: FAIL`) — надёжно откатывали задачу. Устранить ложноотрицательные срабатывания, ломающие конвейер всех проектов.
|
||||
|
||||
## 3. Заинтересованные стороны
|
||||
|
||||
- **Owner / Стрим, Слава** — выявили дефект при разборе ORCH-17 (05.06).
|
||||
- **Все проекты общего прод-инстанса** (orchestrator self-hosting + enduro-trails) — потребители shared quality-gate. Это SHARED-изменение, влияет на всех.
|
||||
- **Тестер-агент** — производитель `13-test-report.md`.
|
||||
|
||||
## 4. Объём работ (scope)
|
||||
|
||||
### В объёме
|
||||
- `_parse_tests_verdict` читает `result:` как первоклассное машиночитаемое поле НАРАВНЕ с `verdict:` и `status:`.
|
||||
- Семантика приоритетов сохраняется и распространяется на все три поля:
|
||||
- negative-токен в ЛЮБОМ из трёх (`result`/`verdict`/`status`) → FAIL и авторитетен (перебивает positive в другом поле);
|
||||
- при отсутствии negative — positive-токен в ЛЮБОМ из трёх → PASS;
|
||||
- ни одно из трёх полей не задано → FAIL (нет машиночитаемого вердикта);
|
||||
- заданы, но не распознаны → FAIL.
|
||||
- Обратная совместимость: отчёты, несущие только `verdict:`/`status:` (стиль enduro-trails ET-001…ET-014, ORCH-016), продолжают работать ровно как раньше.
|
||||
- **ADR** на изменение семантики shared testing-гейта (правило 2 CLAUDE.md — обязательно для сквозного изменения).
|
||||
- Обновление документации: `docs/architecture/README.md` (строка про машинные ключи вердикт-парсера), `CHANGELOG.md`.
|
||||
|
||||
### Вне объёма
|
||||
- Изменение промпта тестера (`.openclaw/agents/tester.md`). Контракт приводится со стороны гейта к тому, что тестеру УЖЕ велено эмитить; промпт не трогаем.
|
||||
- Изменение других гейтов (`check_reviewer_verdict`, `check_deploy_status`, `check_staging_status`) — у них свои поля (`verdict:`, `deploy_status:`, `staging_status:`), они вне этого дефекта.
|
||||
- Изменения ORCH-017 (про ссылки) — это отдельный work item.
|
||||
|
||||
## 5. Ограничения и риски
|
||||
|
||||
- **SHARED quality-gate, общий прод-инстанс.** Изменение затрагивает enduro-trails наравне с orchestrator. Регресс недопустим: набор положительных/отрицательных токенов и поведение для старого формата (`verdict:`/`status:`) должны остаться неизменными.
|
||||
- **Self-hosting.** Орк правит сам себя; деплой проходит через обязательную стадию `deploy-staging` (8501). Прод-контейнер `orchestrator` (8500) не ронять.
|
||||
- Изменение читает только frontmatter, никогда не прозу (канон гейтов из `docs/architecture/README.md`).
|
||||
- Парсер не должен бросать исключения ни при каком вводе (битый YAML, пустой файл, frontmatter-не-mapping) → всегда `(False, reason)`.
|
||||
|
||||
## 6. Эталонный код
|
||||
|
||||
Дев-агент уже написал референс-реализацию в ветке `feature/ORCH-017` (`src/qg/checks.py` + `tests/test_qg.py`, 23 теста). Его допустимо использовать как ориентир, но оформить чисто через данный work item с собственным ADR.
|
||||
|
||||
## 7. Критерий успеха
|
||||
|
||||
Тест-отчёт с одним лишь `result: PASS` проходит гейт `check_tests_passed`; с `result: FAIL` — нет. Старый формат (`verdict:`/`status:`) не регрессирует. Все pytest зелёные. ADR заведён.
|
||||
68
docs/work-items/ORCH-047/02-trz.md
Normal file
68
docs/work-items/ORCH-047/02-trz.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# ТЗ — ORCH-047: `_parse_tests_verdict` читает `result:` наравне с `verdict:`/`status:`
|
||||
|
||||
## 1. Задействованные модули `src/`
|
||||
|
||||
| Файл | Что меняется |
|
||||
|------|--------------|
|
||||
| `src/qg/checks.py` | Функция `_parse_tests_verdict` (стр. ~223–265). Добавить чтение поля `result:` из frontmatter и включить его в проверку токенов наравне с `verdict:`/`status:`. Обновить докстринг функции и `check_tests_passed`. |
|
||||
|
||||
Точка входа `check_tests_passed(repo, work_item_id, branch)` (стр. ~182) и реестр `QG_CHECKS` НЕ меняются (сигнатура и имя гейта те же).
|
||||
|
||||
## 2. Требуемое поведение `_parse_tests_verdict`
|
||||
|
||||
Вход — строковое тело `13-test-report.md`. Выход — `tuple[bool, str]`.
|
||||
|
||||
1. Нет frontmatter (`content` не начинается с `---`) → `(False, "No YAML frontmatter ...")`.
|
||||
2. Frontmatter некорректен (split по `---` даёт < 3 частей) → `(False, "Malformed YAML frontmatter ...")`.
|
||||
3. YAML не парсится → `(False, "Invalid YAML frontmatter ...: <e>")` (никогда не raise).
|
||||
4. YAML не mapping → `(False, "Malformed YAML frontmatter ... (not a mapping)")`.
|
||||
5. Прочитать три поля, нормализовать (`str(...).upper().strip()`, защита от `None`):
|
||||
- `verdict`
|
||||
- `status`
|
||||
- **`result` ← НОВОЕ**
|
||||
6. Если ВСЕ три пусты → `(False, "No machine-readable verdict/status/result in test report frontmatter")`.
|
||||
7. Собрать объединённую строку полей `fields = f"{verdict} {status} {result}"`.
|
||||
8. Если в `fields` встречается ЛЮБОЙ negative-токен (`_TESTS_NEGATIVE_TOKENS`) → `(False, "Test verdict: <значение> (<NEG>)")`. **Negative авторитетен** — проверяется ПЕРВЫМ, перебивает любой positive.
|
||||
9. Иначе если встречается ЛЮБОЙ positive-токен (`_TESTS_POSITIVE_TOKENS`) → `(True, "Test verdict: <значение> (PASS)")`.
|
||||
10. Иначе (заданы, но не распознаны) → `(False, "No recognized PASS verdict in frontmatter (...)")`.
|
||||
|
||||
Наборы токенов НЕ изменяются (важно для обратной совместимости с enduro-trails):
|
||||
```python
|
||||
_TESTS_NEGATIVE_TOKENS = ("BLOCKED", "FAILED", "FAIL", "REQUEST_CHANGES", "REJECT", "RED")
|
||||
_TESTS_POSITIVE_TOKENS = ("PASSED", "PASS", "READY-TO-DEPLOY", "READY_TO_DEPLOY", "GREEN", "APPROVED")
|
||||
```
|
||||
|
||||
> Примечание для разработчика (порядок токенов): negative-список проверяется раньше positive — это даёт авторитетность отрицания. Внутри positive-набора `"PASSED"` идёт перед `"PASS"` лишь для аккуратного reason-текста; на результат (bool) порядок не влияет, т.к. это подстрочный поиск.
|
||||
|
||||
## 3. Контракт поля (golden source)
|
||||
|
||||
После изменения машиночитаемыми полями testing-гейта считаются **три равноправных**: `result:` (канон промпта тестера), `verdict:`, `status:` (легаси/enduro-trails). Достаточно ЛЮБОГО одного. Это и есть приведение гейта к тому, что тестеру велено эмитить в `.openclaw/agents/tester.md` (`result: PASS|FAIL`).
|
||||
|
||||
## 4. Изменения API
|
||||
|
||||
Нет. HTTP-эндпоинты (`/health`, `/status`, `/queue`, вебхуки) не затрагиваются. Сигнатуры функций гейта не меняются.
|
||||
|
||||
## 5. Изменения схемы БД
|
||||
|
||||
Нет.
|
||||
|
||||
## 6. Требования к новым QG checks
|
||||
|
||||
Новых гейтов нет. Меняется внутренняя логика существующего `check_tests_passed` (через `_parse_tests_verdict`). Реестр `QG_CHECKS` без изменений → снапшот-тест `tests/test_qg_registry_snapshot.py` должен остаться зелёным.
|
||||
|
||||
## 7. Артефакты pipeline (создать/обновить в этом PR)
|
||||
|
||||
- `docs/work-items/ORCH-047/06-adr/ADR-001-*.md` — **обязательно** (правило 2 CLAUDE.md): ADR на изменение семантики SHARED testing-гейта (влияет на все проекты общего инстанса). Заводит архитектор.
|
||||
- `docs/architecture/README.md` — обновить строку о вердикт-парсере (раздел «Plane Sync», п. про машинные ключи): для testing-гейта перечислить `result:`/`verdict:`/`status:`.
|
||||
- `CHANGELOG.md` — запись `fix:` про ORCH-047.
|
||||
- `tests/test_qg.py` — добавить кейсы на `result:` (см. `04-test-plan.yaml`).
|
||||
|
||||
## 8. Нефункциональные требования
|
||||
|
||||
- Парсер не бросает исключений ни на каком вводе.
|
||||
- Изменение читает только frontmatter, не прозу (канон гейтов).
|
||||
- Полная обратная совместимость: существующие тесты `TestCheckTestsPassed` остаются зелёными без правок (кроме, возможно, переименования reason-строки в п.6 BRD — текст причины «No machine-readable verdict/status...» обновляется на «...verdict/status/result...», соответствующий ассерт при наличии обновить).
|
||||
|
||||
## 9. Деплой
|
||||
|
||||
Self-hosting: стандартный путь через `deploy-staging` (8501) перед прод-деплоем. Прод-контейнер `orchestrator` (8500) не перезапускать в рамках разработки/тестинга.
|
||||
68
docs/work-items/ORCH-047/03-acceptance-criteria.md
Normal file
68
docs/work-items/ORCH-047/03-acceptance-criteria.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# Критерии приёмки — ORCH-047
|
||||
|
||||
Каждый критерий имеет однозначное условие PASS/FAIL.
|
||||
|
||||
## AC-01 — `result: PASS` проходит гейт (главный кейс ORCH-17)
|
||||
- **Дано:** `13-test-report.md` с frontmatter, содержащим только `result: PASS` (без `verdict:`/`status:`).
|
||||
- **Ожидается:** `check_tests_passed(...)` → `(True, ...)`, в reason присутствует «PASS».
|
||||
- **PASS:** возвращается True. **FAIL:** возвращается False.
|
||||
|
||||
## AC-02 — `result: FAIL` откатывает задачу
|
||||
- **Дано:** frontmatter с `result: FAIL` (без `verdict:`/`status:`).
|
||||
- **Ожидается:** `(False, ...)`, reason содержит токен отрицания (`FAIL`).
|
||||
- **PASS:** False. **FAIL:** True.
|
||||
|
||||
## AC-03 — Negative авторитетен поверх positive (в т.ч. между полями)
|
||||
- **Дано:** `result: PASS`, но `verdict: BLOCKED` (или `status: failed`).
|
||||
- **Ожидается:** `(False, ...)`, reason упоминает negative-токен (`BLOCKED`/`FAILED`).
|
||||
- **PASS:** False. **FAIL:** True.
|
||||
|
||||
## AC-04 — Positive в любом из трёх полей даёт PASS
|
||||
- **Дано (каждый подкейс отдельно):**
|
||||
- только `verdict: PASS`;
|
||||
- только `status: PASSED`;
|
||||
- только `result: ready-to-deploy`.
|
||||
- **Ожидается:** все три → `(True, ...)`.
|
||||
- **PASS:** все True. **FAIL:** хоть один False.
|
||||
|
||||
## AC-05 — Обратная совместимость (enduro-trails / ORCH-016)
|
||||
- **Дано:** существующие реальные формы из `TestCheckTestsPassed`:
|
||||
- `verdict: PASS` + `status: pass`;
|
||||
- `verdict: PASS — ready-to-deploy`;
|
||||
- `verdict: ready-to-deploy` + `status: PASSED`;
|
||||
- `verdict: stage:ready-to-deploy` + `status: pass`;
|
||||
- `verdict: BLOCKED` + проза «23 passed».
|
||||
- **Ожидается:** результаты идентичны прежним (PASS-кейсы → True, BLOCKED → False). Старые тесты `TestCheckTestsPassed` зелёные.
|
||||
- **PASS:** поведение не изменилось. **FAIL:** любой регресс.
|
||||
|
||||
## AC-06 — Ни одно из трёх полей не задано → FAIL
|
||||
- **Дано:** frontmatter без `result`/`verdict`/`status` (например, только `type:`/`version:`); тело может содержать «Result: PASS» прозой.
|
||||
- **Ожидается:** `(False, ...)`, причина про отсутствие машиночитаемого вердикта.
|
||||
- **PASS:** False. **FAIL:** True.
|
||||
|
||||
## AC-07 — Только проза, без frontmatter → FAIL
|
||||
- **Дано:** отчёт без YAML-frontmatter, в теле «Result: PASS / All tests passed».
|
||||
- **Ожидается:** `(False, ...)`, причина про отсутствие frontmatter. Прозу не читаем.
|
||||
- **PASS:** False. **FAIL:** True.
|
||||
|
||||
## AC-08 — Битый YAML → FAIL без исключения
|
||||
- **Дано:** некорректный YAML во frontmatter.
|
||||
- **Ожидается:** `(False, ...)` c упоминанием YAML/frontmatter, функция НЕ бросает исключение.
|
||||
- **PASS:** False и нет raise. **FAIL:** raise или True.
|
||||
|
||||
## AC-09 — Отчёт отсутствует → FAIL
|
||||
- **Дано:** файла `13-test-report.md` нет.
|
||||
- **Ожидается:** `(False, "...not found...")`.
|
||||
- **PASS:** False. **FAIL:** True.
|
||||
|
||||
## AC-10 — Реестр гейтов неизменен
|
||||
- **Ожидается:** `QG_CHECKS` содержит ровно те же ключи, что и до изменения; `tests/test_qg_registry_snapshot.py` зелёный.
|
||||
- **PASS:** снапшот совпал. **FAIL:** снапшот изменился.
|
||||
|
||||
## AC-11 — Документация и ADR обновлены (правило 2/6 CLAUDE.md)
|
||||
- **Ожидается:** заведён `docs/work-items/ORCH-047/06-adr/ADR-001-*.md`; обновлены `docs/architecture/README.md` (вердикт-парсер testing-гейта) и `CHANGELOG.md`.
|
||||
- **PASS:** все три присутствуют и описывают изменение. **FAIL:** что-либо отсутствует → REQUEST_CHANGES на review.
|
||||
|
||||
## AC-12 — Полный регресс зелёный
|
||||
- **Ожидается:** `pytest tests/ -q` — все тесты PASS.
|
||||
- **PASS:** exit code 0. **FAIL:** любой упавший тест.
|
||||
97
docs/work-items/ORCH-047/04-test-plan.yaml
Normal file
97
docs/work-items/ORCH-047/04-test-plan.yaml
Normal file
@@ -0,0 +1,97 @@
|
||||
work_item: ORCH-047
|
||||
module_under_test: src/qg/checks.py::_parse_tests_verdict (via check_tests_passed)
|
||||
test_file: tests/test_qg.py
|
||||
notes: >
|
||||
Добавить в класс TestCheckTestsPassed. Шаблон записи отчёта — существующий
|
||||
хелпер self._write(dir, content). Наборы токенов не меняются; проверяем, что
|
||||
поле result: теперь равноправно с verdict:/status:, а старые кейсы не регрессируют.
|
||||
|
||||
tests:
|
||||
- id: TC-01
|
||||
type: unit
|
||||
description: "result: PASS без verdict/status -> гейт PASS (главный кейс ORCH-17, AC-01)"
|
||||
module: tests/test_qg.py
|
||||
fixture_frontmatter: "---\ntype: test-report\nresult: PASS\n---\n\n# Test Report\n"
|
||||
expected: PASS
|
||||
|
||||
- id: TC-02
|
||||
type: unit
|
||||
description: "result: FAIL без verdict/status -> гейт FAIL, reason содержит FAIL (AC-02)"
|
||||
module: tests/test_qg.py
|
||||
fixture_frontmatter: "---\nresult: FAIL\n---\n\nbody\n"
|
||||
expected: FAIL
|
||||
|
||||
- id: TC-03
|
||||
type: unit
|
||||
description: "result: PASS, но verdict: BLOCKED -> negative авторитетен -> FAIL (AC-03)"
|
||||
module: tests/test_qg.py
|
||||
fixture_frontmatter: "---\nresult: PASS\nverdict: BLOCKED\n---\n\n23 passed\n"
|
||||
expected: FAIL
|
||||
|
||||
- id: TC-04
|
||||
type: unit
|
||||
description: "result: PASS, но status: failed -> negative авторитетен -> FAIL (AC-03)"
|
||||
module: tests/test_qg.py
|
||||
fixture_frontmatter: "---\nresult: PASS\nstatus: failed\n---\n\nbody\n"
|
||||
expected: FAIL
|
||||
|
||||
- id: TC-05
|
||||
type: unit
|
||||
description: "result: ready-to-deploy (positive-токен, без слова PASS) -> PASS (AC-04)"
|
||||
module: tests/test_qg.py
|
||||
fixture_frontmatter: "---\nresult: ready-to-deploy\n---\n\nbody\n"
|
||||
expected: PASS
|
||||
|
||||
- id: TC-06
|
||||
type: unit
|
||||
description: "Только verdict: PASS (легаси) -> PASS, без регресса (AC-05)"
|
||||
module: tests/test_qg.py
|
||||
fixture_frontmatter: "---\nverdict: PASS\nstatus: pass\n---\n\nbody\n"
|
||||
expected: PASS
|
||||
|
||||
- id: TC-07
|
||||
type: unit
|
||||
description: "verdict: BLOCKED + проза '23 passed' (ET-013 баг) -> FAIL, без регресса (AC-05)"
|
||||
module: tests/test_qg.py
|
||||
fixture_frontmatter: "---\nverdict: BLOCKED\n---\n\nTests: 23 passed, 0 failed.\n"
|
||||
expected: FAIL
|
||||
|
||||
- id: TC-08
|
||||
type: unit
|
||||
description: "Ни result, ни verdict, ни status; тело с прозой 'Result: PASS' -> FAIL (AC-06)"
|
||||
module: tests/test_qg.py
|
||||
fixture_frontmatter: "---\ntype: test-report\nversion: 1\n---\n\nResult: PASS\n"
|
||||
expected: FAIL
|
||||
|
||||
- id: TC-09
|
||||
type: unit
|
||||
description: "Нет frontmatter, проза 'Result: PASS' -> FAIL (AC-07)"
|
||||
module: tests/test_qg.py
|
||||
fixture_frontmatter: "# Test Report\n\nResult: PASS\nAll tests passed.\n"
|
||||
expected: FAIL
|
||||
|
||||
- id: TC-10
|
||||
type: unit
|
||||
description: "Битый YAML во frontmatter -> FAIL без исключения, reason про YAML/frontmatter (AC-08)"
|
||||
module: tests/test_qg.py
|
||||
fixture_frontmatter: "---\nresult: [unclosed\n : : :\n---\n\nbody PASS\n"
|
||||
expected: FAIL
|
||||
|
||||
- id: TC-11
|
||||
type: unit
|
||||
description: "Файл 13-test-report.md отсутствует -> FAIL, reason 'not found' (AC-09)"
|
||||
module: tests/test_qg.py
|
||||
fixture_frontmatter: null
|
||||
expected: FAIL
|
||||
|
||||
- id: TC-12
|
||||
type: unit
|
||||
description: "Реестр QG_CHECKS не изменился -> снапшот зелёный (AC-10)"
|
||||
module: tests/test_qg_registry_snapshot.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-13
|
||||
type: integration
|
||||
description: "Полный регресс pytest tests/ -q зелёный, существующий TestCheckTestsPassed без правок логики (AC-05, AC-12)"
|
||||
module: tests/
|
||||
expected: PASS
|
||||
@@ -0,0 +1,80 @@
|
||||
# ADR-001: testing-гейт читает `result:` наравне с `verdict:`/`status:`
|
||||
|
||||
- **Статус:** Accepted
|
||||
- **Дата:** 2026-06-05
|
||||
- **Задача:** ORCH-047
|
||||
- **Область:** SHARED quality-gate `check_tests_passed` (общий прод-инстанс: orchestrator + enduro-trails)
|
||||
|
||||
## Контекст
|
||||
|
||||
Quality Gate `check_tests_passed` (`src/qg/checks.py`, парсер `_parse_tests_verdict`) гейтит
|
||||
переход `testing → deploy-staging`, читая машиночитаемый вердикт ТОЛЬКО из YAML-frontmatter
|
||||
артефакта `13-test-report.md` (канон гейтов: frontmatter, никогда не проза — см.
|
||||
`docs/architecture/README.md`).
|
||||
|
||||
Существует рассинхрон контракта между производителем и потребителем вердикта:
|
||||
|
||||
- **Потребитель** (`_parse_tests_verdict`) читает поля `verdict:` и `status:`.
|
||||
- **Производитель** (`.openclaw/agents/tester.md`, строки 51–56, 78–80) предписывает тестеру
|
||||
эмитить машиночитаемое поле **`result: PASS|FAIL`** и НЕ упоминает `verdict:`/`status:`.
|
||||
|
||||
Тестер, честно следуя своей инструкции, пишет `result: PASS` без `verdict:`/`status:`. Парсер
|
||||
попадает в ветку «ни verdict, ни status не заданы» → `(False, "No machine-readable
|
||||
verdict/status…")` → откат `testing → development` и петля до исчерпания
|
||||
`MAX_DEVELOPER_RETRIES`. Это наблюдалось на ORCH-17; ORCH-016 прошёл лишь потому, что его отчёт
|
||||
избыточно нёс И `verdict:`, И `result:`.
|
||||
|
||||
Корень — несовпадение имён поля контракта, а не логики токенов. Наборы positive/negative-токенов
|
||||
исправны и менять их нельзя (обратная совместимость с реальными отчётами enduro-trails
|
||||
ET-001…ET-014).
|
||||
|
||||
## Решение
|
||||
|
||||
Привести контракт гейта к тому, что тестеру УЖЕ велено эмитить — со стороны гейта, не трогая
|
||||
промпт тестера.
|
||||
|
||||
1. `_parse_tests_verdict` читает **три равноправных** машиночитаемых поля из frontmatter:
|
||||
`result:` (канон промпта тестера), `verdict:`, `status:` (легаси/enduro-trails). Достаточно
|
||||
ЛЮБОГО одного непустого.
|
||||
2. Семантика приоритетов сохраняется и распространяется на все три поля через объединённую строку
|
||||
`fields = f"{verdict} {status} {result}"`:
|
||||
- negative-токен (`_TESTS_NEGATIVE_TOKENS`) в любом поле → FAIL и **авторитетен** (проверяется
|
||||
первым, перебивает positive в другом поле);
|
||||
- иначе positive-токен (`_TESTS_POSITIVE_TOKENS`) в любом поле → PASS;
|
||||
- ни одно из трёх не задано → FAIL («No machine-readable verdict/status/result…»);
|
||||
- заданы, но не распознаны → FAIL.
|
||||
3. Наборы токенов **не изменяются**.
|
||||
4. Парсер не бросает исключений ни на каком вводе (битый YAML, пустой файл, frontmatter-не-mapping)
|
||||
→ всегда `(False, reason)`.
|
||||
5. Сигнатура `check_tests_passed`, имя гейта и реестр `QG_CHECKS` **не меняются** — снапшот
|
||||
`tests/test_qg_registry_snapshot.py` остаётся зелёным.
|
||||
|
||||
### Альтернативы (отклонены)
|
||||
|
||||
- **Править промпт тестера** (`verdict:` вместо `result:`) — отклонено: контракт уже задокументирован
|
||||
для тестера как `result:`; единичная правка гейта дешевле и не требует переучивать агента, плюс
|
||||
ломала бы совместимость со старыми отчётами, где встречается `verdict:`/`status:`.
|
||||
- **Глобальный ADR в `docs/architecture/adr/`** — не требуется: изменение не добавляет гейт/стадию/
|
||||
компонент и не меняет топологию; это приведение парсинга существующего гейта к контракту. Канон
|
||||
гейтов в README обновляется точечно.
|
||||
|
||||
## Последствия
|
||||
|
||||
- **Плюс:** корректные отчёты `result: PASS` проходят гейт; `result: FAIL` надёжно откатывает.
|
||||
Петля `testing ↔ development` устранена для всех проектов общего инстанса.
|
||||
- **Плюс:** полная обратная совместимость — отчёты только с `verdict:`/`status:` работают как
|
||||
раньше; существующие тесты `TestCheckTestsPassed` зелёные без правок (кроме обновления reason-текста
|
||||
«…verdict/status…» → «…verdict/status/result…»).
|
||||
- **Минус/ограничение:** число распознаваемых имён поля растёт до трёх — формально шире поверхность
|
||||
«случайного PASS». Митигируется тем, что negative-токен авторитетен и читается только frontmatter.
|
||||
- **SHARED-риск:** изменение затрагивает enduro-trails наравне с orchestrator. Регресс по наборам
|
||||
токенов недопустим → они заморожены; покрытие — `04-test-plan.yaml` (AC-04/AC-05).
|
||||
- **Self-hosting:** деплой строго через `deploy-staging` (8501); прод-контейнер `orchestrator`
|
||||
(8500) не перезапускать в рамках разработки/тестинга.
|
||||
|
||||
## Связи
|
||||
|
||||
- BRD/ТЗ: `docs/work-items/ORCH-047/01-brd.md`, `02-trz.md`.
|
||||
- Канон гейтов и вердикт-парсер: `docs/architecture/README.md`.
|
||||
- Промпт-производитель: `.openclaw/agents/tester.md` (`result: PASS|FAIL`).
|
||||
- adr-0003 (staging-гейт) — обязательная страховка перед прод-деплоем self.
|
||||
10
docs/work-items/ORCH-047/10-tech-risks.md
Normal file
10
docs/work-items/ORCH-047/10-tech-risks.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Технические риски — ORCH-047
|
||||
|
||||
| # | Риск | Вероятность | Влияние | Митигация |
|
||||
|---|------|-------------|---------|-----------|
|
||||
| R-1 | Регресс набора токенов ломает enduro-trails (SHARED-гейт, общий прод-инстанс) | Низкая | Высокое | Наборы `_TESTS_NEGATIVE_TOKENS`/`_TESTS_POSITIVE_TOKENS` **заморожены** (не трогать). Покрытие AC-05 на реальных формах ET-001…ET-014 + ORCH-016. |
|
||||
| R-2 | Новое поле `result:` расширяет поверхность ложного PASS | Низкая | Среднее | Negative-токен авторитетен (проверяется первым, перебивает positive). Читается только frontmatter, не проза (AC-03, AC-06, AC-07). |
|
||||
| R-3 | Парсер бросает исключение на битом вводе → падение `_run_qg` | Низкая | Высокое | Defensive-контракт сохранён: любой ввод (нет frontmatter / битый YAML / не-mapping / пустой) → `(False, reason)`, никогда raise (AC-08). |
|
||||
| R-4 | Незаметное изменение реестра гейтов | Очень низкая | Среднее | Сигнатура, имя гейта и `QG_CHECKS` неизменны; снапшот `tests/test_qg_registry_snapshot.py` зелёный (AC-10). |
|
||||
| R-5 | Self-hosting: деплой роняет прод-контейнер всех проектов | Низкая | Высокое | Деплой только через `deploy-staging` (8501); прод `orchestrator` (8500) не перезапускать в dev/test (CLAUDE.md, adr-0003). |
|
||||
| R-6 | Изменение поведения без обновления golden-source доки → REQUEST_CHANGES на review | Средняя | Низкое | ADR-001 заведён; `docs/architecture/README.md` (вердикт-парсер) обновлён архитектором; `CHANGELOG.md` — дев в том же PR (AC-11). |
|
||||
62
docs/work-items/ORCH-047/12-review.md
Normal file
62
docs/work-items/ORCH-047/12-review.md
Normal file
@@ -0,0 +1,62 @@
|
||||
---
|
||||
type: review
|
||||
work_item_id: ORCH-047
|
||||
verdict: APPROVED
|
||||
version: 3
|
||||
---
|
||||
|
||||
# Review ORCH-047
|
||||
|
||||
## Summary
|
||||
Гейт `check_tests_passed` (через `_parse_tests_verdict`) теперь читает `result:` наравне с
|
||||
`verdict:`/`status:`. Реализация точно соответствует ТЗ (`02-trz.md`), ADR-001 и критериям
|
||||
приёмки. Независимый прогон: `pytest tests/ -q` → **442 passed**; снапшот реестра гейтов не
|
||||
изменился. Документация (README, ADR-001, CHANGELOG) обновлена в том же PR. Блокеров и
|
||||
must-fix нет → APPROVED.
|
||||
|
||||
## Findings
|
||||
|
||||
### P0 — Blocker
|
||||
- нет
|
||||
|
||||
### P1 — Must fix
|
||||
- нет
|
||||
|
||||
### P2 — Should fix
|
||||
- нет
|
||||
|
||||
### P3 — Nice-to-have
|
||||
- [ ] Докстринг `check_tests_passed` (≈стр. 184) по-прежнему говорит «Gate the testing ->
|
||||
deploy transition», тогда как фактический переход — `testing → deploy-staging`.
|
||||
Несоответствие предсуществующее, этим PR не введено; чистая косметика, не блокирует.
|
||||
|
||||
## Соответствие ТЗ и AC
|
||||
- **ТЗ §2** — все 10 правил поведения реализованы: чтение `result:` (стр. 261, нормализация
|
||||
`str(...).upper().strip()` + защита от `None`); все три пусты → корректная reason-строка
|
||||
«...verdict/status/result...» (стр. 263–264); объединённая строка `fields = "{verdict}
|
||||
{status} {result}"` (стр. 267); negative-токен проверяется ПЕРВЫМ и авторитетен
|
||||
(стр. 268–270); positive (стр. 271–273); fallback на нераспознанные (стр. 275–279).
|
||||
Наборы `_TESTS_NEGATIVE_TOKENS`/`_TESTS_POSITIVE_TOKENS` не тронуты. ✅
|
||||
- **ТЗ §4/§5/§6** — сигнатура `check_tests_passed`, имя гейта, `QG_CHECKS`, HTTP-API, схема БД
|
||||
не изменены. Снапшот `tests/test_qg_registry_snapshot.py` зелёный (AC-10). ✅
|
||||
- **AC-01..AC-09** — покрыты новыми кейсами в `TestCheckTestsPassed`: `result: PASS/FAIL`,
|
||||
авторитетность negative между полями (`verdict: BLOCKED`, `status: failed` поверх
|
||||
`result: PASS`), `result: ready-to-deploy`, отсутствие машинных полей (reason упоминает
|
||||
`result`). Легаси-кейсы остались зелёными без правок логики (AC-05). ✅
|
||||
- **AC-12** — `pytest tests/ -q` → 442 passed (независимый прогон ревьюера). ✅
|
||||
|
||||
## Соответствие ADR
|
||||
- ADR-001 (`06-adr/ADR-001-result-field-in-tests-gate.md`): решение «три равноправных поля,
|
||||
токены заморожены, negative авторитетен, реестр/сигнатура неизменны» полностью отражено
|
||||
в коде.
|
||||
- Глобальный ADR обоснованно не требуется (изменение не добавляет гейт/стадию/компонент,
|
||||
не меняет топологию) — согласуется с конвенцией CLAUDE.md. SHARED-риск общего инстанса
|
||||
(orchestrator + enduro-trails) учтён: токены заморожены, обратная совместимость покрыта
|
||||
тестами.
|
||||
|
||||
## Документация
|
||||
ОБНОВЛЕНА в том же PR (правило 2/6 CLAUDE.md, AC-11):
|
||||
- `docs/architecture/README.md` — строка вердикт-парсера: для testing-гейта перечислены
|
||||
`result:`/`verdict:`/`status:` + пометка про авторитетность negative. ✅
|
||||
- `docs/work-items/ORCH-047/06-adr/ADR-001-result-field-in-tests-gate.md` — заведён. ✅
|
||||
- `CHANGELOG.md` — запись в `Fixed` про ORCH-047. ✅
|
||||
78
docs/work-items/ORCH-047/13-test-report.md
Normal file
78
docs/work-items/ORCH-047/13-test-report.md
Normal file
@@ -0,0 +1,78 @@
|
||||
---
|
||||
type: test-report
|
||||
work_item_id: ORCH-047
|
||||
result: PASS
|
||||
---
|
||||
|
||||
# Test Report — ORCH-047
|
||||
|
||||
`check_tests_passed` / `_parse_tests_verdict` читает `result:` наравне с `verdict:`/`status:`.
|
||||
|
||||
## Окружение
|
||||
- Python: 3.12.13
|
||||
- pytest: 8.3.3
|
||||
- Ветка: feature/ORCH-047-check-tests-passed-gate-must-r
|
||||
- Среда: dev worktree (прод-контейнер `orchestrator` :8500 не затронут)
|
||||
- Дата: 2026-06-05
|
||||
|
||||
## Smoke test API (prod :8500, read-only)
|
||||
| Endpoint | Результат |
|
||||
|----------|-----------|
|
||||
| `GET /health` | `{"status":"ok","service":"orchestrator"}` — OK |
|
||||
| `GET /status` | 200, активные задачи отдаются (ORCH-047 в testing) — OK |
|
||||
| `GET /queue` | 200, counts/breaker/preflight в норме (running:1, failed:0) — OK |
|
||||
|
||||
## Результаты (план `04-test-plan.yaml`)
|
||||
|
||||
| TC ID | Описание | Тест | Результат |
|
||||
|-------|----------|------|-----------|
|
||||
| TC-01 | `result: PASS` без verdict/status → PASS (AC-01) | `test_result_pass_passes` | PASS |
|
||||
| TC-02 | `result: FAIL` → FAIL, reason содержит FAIL (AC-02) | `test_result_fail_fails` | PASS |
|
||||
| TC-03 | `result: PASS` + `verdict: BLOCKED` → negative авторитетен → FAIL (AC-03) | `test_result_pass_but_verdict_blocked_fails` | PASS |
|
||||
| TC-04 | `result: PASS` + `status: failed` → FAIL (AC-03) | `test_result_pass_but_status_failed_fails` | PASS |
|
||||
| TC-05 | `result: ready-to-deploy` → PASS (AC-04) | `test_result_ready_to_deploy_passes` | PASS |
|
||||
| TC-06 | Легаси `verdict: PASS` → PASS, без регресса (AC-05) | `test_verdict_pass_passes` | PASS |
|
||||
| TC-07 | `verdict: BLOCKED` + проза «23 passed» → FAIL (AC-05) | `test_passed_count_in_body_but_blocked_verdict_fails` | PASS |
|
||||
| TC-08 | Нет машинных полей, проза «Result: PASS» → FAIL (AC-06) | `test_no_machine_field_reason_mentions_result` | PASS |
|
||||
| TC-09 | Нет frontmatter → FAIL (AC-07) | `test_no_frontmatter_fails` | PASS |
|
||||
| TC-10 | Битый YAML → FAIL без исключения (AC-08) | `test_invalid_yaml_fails_no_exception` | PASS |
|
||||
| TC-11 | Отчёт отсутствует → FAIL «not found» (AC-09) | `test_no_report` | PASS |
|
||||
| TC-12 | Реестр `QG_CHECKS` неизменен (AC-10) | `test_qg_registry_snapshot.py` (3 теста) | PASS |
|
||||
| TC-13 | Полный регресс зелёный (AC-05, AC-12) | `pytest tests/` | PASS |
|
||||
|
||||
## Покрытие критериев приёмки
|
||||
|
||||
| AC | Статус |
|
||||
|----|--------|
|
||||
| AC-01 `result: PASS` проходит | PASS |
|
||||
| AC-02 `result: FAIL` откатывает | PASS |
|
||||
| AC-03 negative авторитетен между полями | PASS |
|
||||
| AC-04 positive в любом из трёх полей → PASS | PASS |
|
||||
| AC-05 обратная совместимость (TestCheckTestsPassed) | PASS |
|
||||
| AC-06 ни одно поле не задано → FAIL | PASS |
|
||||
| AC-07 только проза без frontmatter → FAIL | PASS |
|
||||
| AC-08 битый YAML → FAIL без raise | PASS |
|
||||
| AC-09 отчёт отсутствует → FAIL | PASS |
|
||||
| AC-10 реестр гейтов неизменен | PASS |
|
||||
| AC-11 ADR/README/CHANGELOG обновлены | PASS |
|
||||
| AC-12 полный регресс зелёный | PASS |
|
||||
|
||||
AC-11 проверено вручную:
|
||||
- `docs/work-items/ORCH-047/06-adr/ADR-001-result-field-in-tests-gate.md` — присутствует.
|
||||
- `docs/architecture/README.md` — строка вердикт-парсера перечисляет `result:`/`verdict:`/`status:`.
|
||||
- `CHANGELOG.md` — запись `fix:` про ORCH-047.
|
||||
|
||||
## Вывод pytest
|
||||
```
|
||||
tests/test_qg.py ............................... TestCheckTestsPassed (все PASS,
|
||||
включая новые test_result_* и легаси-кейсы)
|
||||
tests/test_qg_registry_snapshot.py::test_tc20_qg_callables_unchanged PASSED
|
||||
tests/test_qg_registry_snapshot.py::test_tc20_stage_transitions_unchanged PASSED
|
||||
...
|
||||
======================== 442 passed, 1 warning in 7.77s ========================
|
||||
```
|
||||
(1 warning — предсуществующий PydanticDeprecatedSince20 в `src/config.py`, не связан с ORCH-047.)
|
||||
|
||||
## Итог
|
||||
PASS — все 13 TC и 12 AC выполнены, полный регресс зелёный (442 passed), smoke OK,
|
||||
реестр гейтов не изменён. Задача готова к стадии deploy-staging.
|
||||
83
docs/work-items/ORCH-047/15-staging-log.md
Normal file
83
docs/work-items/ORCH-047/15-staging-log.md
Normal file
@@ -0,0 +1,83 @@
|
||||
---
|
||||
staging_status: FAILED
|
||||
timestamp: 2026-06-05T21:30:45Z
|
||||
base_url: http://localhost:8501
|
||||
mode: stub
|
||||
result: 9/10 checks PASS
|
||||
exit_code: 1
|
||||
---
|
||||
|
||||
# Staging Gate Log — ORCH-047
|
||||
|
||||
Staging test suite **FAILED**: 9/10 checks passed, exit code 1.
|
||||
|
||||
## Verdict
|
||||
|
||||
The live staging service on `:8501` is healthy and the full E2E pipeline ran
|
||||
correctly against the **sandbox** project (issue created → webhook accepted →
|
||||
branch created in `orchestrator-sandbox` → analyst job enqueued → cleanup OK).
|
||||
|
||||
The single failing check is **B6 — Registry isolation**: the project registry as
|
||||
seen by the test harness still contains the production projects
|
||||
(`enduro-trails`, `ORCH`) and does **not** isolate to the sandbox project only.
|
||||
This violates the staging isolation requirement (CLAUDE.md: "staging — только
|
||||
sandbox-проект"). Because the staging gate returned a non-zero exit code, the
|
||||
machine verdict is `FAILED` and the task is rolled back to `development`.
|
||||
|
||||
### Notes for follow-up (development)
|
||||
|
||||
- B6 imports `src.projects.known_plane_project_ids()` and asserts the registry
|
||||
contains the sandbox id (`8c5a3025-…`) while the prod ids
|
||||
(`7a79f0a9-…` ET, `8da6aa25-…` ORCH) are absent. It observed
|
||||
`sandbox=NO, prod-ET=YES(BAD!), prod-ORCH=YES(BAD!)`.
|
||||
- This is a staging-environment / registry-isolation signal, not a verdict on the
|
||||
ORCH-047 code change itself (which targets the `check_tests_passed` gate).
|
||||
Investigate whether the staging container's isolated project registry env is
|
||||
loaded, or whether the harness's in-process registry import is reading the host
|
||||
(`/repos/orchestrator`) prod env instead of the container's env.
|
||||
- Deployer did **not** modify any production infrastructure, registry, `.env`,
|
||||
or `docker-compose.yml` to alter this result (per deployer mandate).
|
||||
|
||||
## Full test output
|
||||
|
||||
```
|
||||
============================================================
|
||||
ORCH-33 Staging Check Suite
|
||||
base_url : http://localhost:8501
|
||||
mode : stub
|
||||
utc_time : 2026-06-05T21:30:45.071676+00:00
|
||||
============================================================
|
||||
|
||||
[Block A] SMOKE
|
||||
✓ PASS A1 GET /health → 200 status=ok [HTTP 200, body={'status': 'ok', 'service': 'orchestrator'}]
|
||||
✓ PASS A2 GET /queue → 200 with counts/max_concurrency/resilience [HTTP 200, keys=['counts', 'max_concurrency', 'poll_interval', 'resilience', 'recent']]
|
||||
✓ PASS A3 ORCH_STAGING=true (not prod) [ORCH_STAGING=true]
|
||||
|
||||
[Block B] ACCESS
|
||||
✓ PASS B4 Plane: sandbox project accessible [HTTP 200, found 5 project(s), sandbox=YES]
|
||||
✓ PASS B5 Gitea: orchestrator-sandbox accessible, push=true [HTTP 200, permissions={'admin': True, 'push': True, 'pull': True}]
|
||||
✗ FAIL B6 Registry: sandbox present, prod ET/ORCH absent [sandbox=NO, prod-ET=YES(BAD!), prod-ORCH=YES(BAD!)]
|
||||
|
||||
[Block C] E2E (mode=stub)
|
||||
· C7: Creating issue in SANDBOX project...
|
||||
✓ PASS C7 Create issue in Plane SANDBOX [HTTP 201, issue_id=5040c202-592f-45d0-9463-ca1e9944e6ba]
|
||||
· C8: Triggering pipeline via POST /webhook/plane ...
|
||||
· Using HMAC signature (secret len=40)
|
||||
✓ PASS C8 Trigger pipeline via /webhook/plane [HTTP 200, resp={'status': 'accepted'}]
|
||||
· C9a: Polling for branch in orchestrator-sandbox (up to 60s)...
|
||||
✓ PASS C9a Branch appears in orchestrator-sandbox [branch=feature/SANDBOX-010-staging-check-e2e-20260605t213]
|
||||
· C9b: Checking staging job queue for analyst job (up to 30s)...
|
||||
· (Plane comment check skipped: bot-tokens not added to SANDBOX project)
|
||||
✓ PASS C9b Analyst job enqueued in staging queue [job_id=6, status=queued, agent=analyst]
|
||||
|
||||
[CLEANUP]
|
||||
✓ PASS CLEANUP: deleted branch 'feature/SANDBOX-010-staging-check-e2e-20260605t213' (HTTP 204)
|
||||
✓ PASS CLEANUP: deleted Plane issue 5040c202-592f-45d0-9463-ca1e9944e6ba (HTTP 204)
|
||||
· CLEANUP DB: no task row found for plane_id=5040c202-592f-45d0-9463-ca1e9944e6ba
|
||||
· CLEANUP DB dedup: no such table: events_dedup
|
||||
|
||||
============================================================
|
||||
RESULT: 9/10 checks PASS
|
||||
============================================================
|
||||
EXIT_CODE=1
|
||||
```
|
||||
@@ -15,6 +15,82 @@ 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).
|
||||
@@ -85,7 +161,6 @@ 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",
|
||||
@@ -96,7 +171,6 @@ 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",
|
||||
@@ -171,6 +245,12 @@ 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)
|
||||
|
||||
@@ -204,8 +284,14 @@ class AgentLauncher:
|
||||
system_prompt = config["system_prompt"]
|
||||
allowed_tools = config["allowed_tools"]
|
||||
|
||||
model = config.get("model", "")
|
||||
# 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_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.
|
||||
@@ -218,7 +304,7 @@ class AgentLauncher:
|
||||
f'cd {work_path} && '
|
||||
f'{self.CLAUDE_BIN} --print '
|
||||
f'--output-format json '
|
||||
f'{model_flag}'
|
||||
f'{model_flag}{effort_flag}{fb_flag}'
|
||||
f'"$(cat {task_file})" '
|
||||
f'--system-prompt "$(cat {system_prompt})" '
|
||||
f'--allowedTools {allowed_tools}'
|
||||
|
||||
@@ -4,6 +4,11 @@ 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 = ""
|
||||
@@ -78,6 +83,34 @@ 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
|
||||
@@ -88,6 +121,15 @@ class Settings(BaseSettings):
|
||||
log_keep_max: int = 500
|
||||
|
||||
|
||||
# ORCH-045: quality-gate CI poll/retry. check_ci_green polls the Gitea
|
||||
# combined commit status up to ci_poll_max_attempts times, sleeping
|
||||
# ci_poll_interval_s between attempts, to ride out a transient pending
|
||||
# state right after the developer push (race fix, see ORCH-017).
|
||||
# ci_poll_max_attempts -> max status polls (env ORCH_CI_POLL_MAX_ATTEMPTS)
|
||||
# ci_poll_interval_s -> seconds between polls (env ORCH_CI_POLL_INTERVAL_S)
|
||||
ci_poll_max_attempts: int = 12
|
||||
ci_poll_interval_s: int = 10
|
||||
|
||||
# Telegram notifications
|
||||
telegram_bot_token: str = ""
|
||||
telegram_chat_id: str = ""
|
||||
|
||||
@@ -544,6 +544,105 @@ 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.
|
||||
|
||||
@@ -557,10 +656,27 @@ 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 {work_item_id}: BRD/\u0422\u0417/AC \u0433\u043e\u0442\u043e\u0432\u044b. "
|
||||
f"\U0001f4cb {html.escape(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
|
||||
|
||||
@@ -17,7 +17,7 @@ registry is used so the system works out of the box.
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .config import settings
|
||||
|
||||
@@ -30,6 +30,11 @@ 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).
|
||||
@@ -50,6 +55,23 @@ _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():
|
||||
@@ -75,6 +97,8 @@ 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:
|
||||
|
||||
113
src/qg/checks.py
113
src/qg/checks.py
@@ -1,6 +1,7 @@
|
||||
"""Quality Gate checks — real implementations using Gitea/Plane API and filesystem."""
|
||||
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
import subprocess
|
||||
import httpx
|
||||
@@ -82,23 +83,65 @@ def check_ci_green(repo: str, branch: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Check if CI status is green for branch via Gitea API.
|
||||
GET /repos/{owner}/{repo}/commits/{branch}/status
|
||||
|
||||
ORCH-045: polling with retry to fix a race condition. The gate used to do a
|
||||
single status read right after the developer push; if CI was still ``pending``
|
||||
for the first 1-3s (real case ORCH-017: polled 17:58:54 -> pending, CI went
|
||||
green 17:58:55) the gate returned False once and the task stalled silently.
|
||||
|
||||
Behaviour now:
|
||||
* ``success`` -> (True, "CI green") immediately.
|
||||
* ``failure`` / ``error`` -> (False, "CI state: <state>") immediately
|
||||
(CI is red, retrying is pointless).
|
||||
* ``pending`` / unknown -> sleep ``ci_poll_interval_s`` and poll again,
|
||||
up to ``ci_poll_max_attempts`` times.
|
||||
* still pending after all attempts -> (False, "CI still pending after <T>s").
|
||||
* 404 -> (False, "Branch not found or no status").
|
||||
* transient httpx errors -> logged and retried within the attempt budget;
|
||||
if every attempt errors -> (False, "API error: <e>").
|
||||
"""
|
||||
owner = settings.gitea_owner
|
||||
url = f"{GITEA_BASE}/repos/{owner}/{repo}/commits/{branch}/status"
|
||||
|
||||
try:
|
||||
resp = httpx.get(url, headers=GITEA_HEADERS, timeout=10)
|
||||
if resp.status_code == 404:
|
||||
return False, f"Branch '{branch}' not found or no status"
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
state = data.get("state", "unknown")
|
||||
if state == "success":
|
||||
return True, "CI green"
|
||||
return False, f"CI state: {state}"
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Gitea API error checking CI: {e}")
|
||||
return False, f"API error: {e}"
|
||||
attempts = settings.ci_poll_max_attempts
|
||||
interval = settings.ci_poll_interval_s
|
||||
last_state = "unknown"
|
||||
last_error: Exception | None = None
|
||||
|
||||
for i in range(1, attempts + 1):
|
||||
try:
|
||||
resp = httpx.get(url, headers=GITEA_HEADERS, timeout=10)
|
||||
if resp.status_code == 404:
|
||||
return False, f"Branch '{branch}' not found or no status"
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
last_state = data.get("state", "unknown")
|
||||
last_error = None
|
||||
|
||||
if last_state == "success":
|
||||
return True, "CI green"
|
||||
if last_state in ("failure", "error"):
|
||||
return False, f"CI state: {last_state}"
|
||||
# non-terminal (pending / unknown / other) -> retry below
|
||||
except httpx.HTTPError as e:
|
||||
last_error = e
|
||||
logger.error(f"check_ci_green: attempt {i}/{attempts} API error: {e}")
|
||||
|
||||
if i < attempts:
|
||||
if last_error is not None:
|
||||
logger.info(
|
||||
f"check_ci_green: attempt {i}/{attempts}, error, retrying in {interval}s"
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"check_ci_green: attempt {i}/{attempts}, state={last_state}, "
|
||||
f"retrying in {interval}s"
|
||||
)
|
||||
time.sleep(interval)
|
||||
|
||||
if last_error is not None:
|
||||
return False, f"API error: {last_error}"
|
||||
return False, f"CI still pending after {attempts * interval}s"
|
||||
|
||||
|
||||
def check_review_approved(repo: str, pr_number: int) -> tuple[bool, str]:
|
||||
@@ -145,8 +188,11 @@ def check_tests_passed(repo: str, work_item_id: str, branch: str | None = None)
|
||||
explicitly marked `verdict: BLOCKED` / `status: blocked` but whose prose mentioned
|
||||
"23 passed" / "✅ PASS" / "All checks passed" was treated as a pass, and an
|
||||
unfinished feature reached Done. This mirrors check_reviewer_verdict (S-5) and
|
||||
check_deploy_status (БАГ 8): read ONLY the YAML frontmatter `verdict:` / `status:`
|
||||
fields, never the body.
|
||||
check_deploy_status (БАГ 8): read ONLY the YAML frontmatter, never the body.
|
||||
|
||||
ORCH-047: the machine verdict is read from any of three equal-rank frontmatter
|
||||
fields — `result:` (canonical, what the tester prompt emits), `verdict:` or
|
||||
`status:` (legacy / enduro-trails). See _parse_tests_verdict.
|
||||
|
||||
File: docs/work-items/<work_item_id>/13-test-report.md
|
||||
"""
|
||||
@@ -179,15 +225,20 @@ _TESTS_POSITIVE_TOKENS = ("PASSED", "PASS", "READY-TO-DEPLOY", "READY_TO_DEPLOY"
|
||||
|
||||
def _parse_tests_verdict(content: str) -> tuple[bool, str]:
|
||||
"""Map a 13-test-report.md body to a quality-gate verdict by reading ONLY the
|
||||
machine-readable `verdict:` (and corroborating `status:`) YAML frontmatter fields.
|
||||
machine-readable YAML frontmatter fields — never the prose body.
|
||||
|
||||
Three equal-rank fields are accepted (ORCH-047): `result:` (the canonical field
|
||||
the tester prompt `.openclaw/agents/tester.md` is told to emit, `result: PASS|FAIL`),
|
||||
plus `verdict:` and `status:` (legacy / enduro-trails ET-001..ET-014). ANY single
|
||||
non-empty field is sufficient. Token sets are frozen for backward compatibility.
|
||||
|
||||
Rules:
|
||||
- No frontmatter / bad YAML / neither field present -> (False, reason).
|
||||
- A negative token (BLOCKED/FAILED/...) in verdict OR status -> (False) and is
|
||||
authoritative (ET-013 main case: verdict BLOCKED wins over any prose PASS).
|
||||
- Otherwise a positive token (PASS/PASSED/READY-TO-DEPLOY/...) in verdict OR
|
||||
status -> (True).
|
||||
- Anything else (unrecognized / empty verdict) -> (False, reason).
|
||||
- No frontmatter / bad YAML / none of the three fields present -> (False, reason).
|
||||
- A negative token (BLOCKED/FAILED/...) in ANY field -> (False) and is
|
||||
authoritative (ET-013 main case: verdict BLOCKED wins over any prose PASS, and
|
||||
beats a positive token in another field).
|
||||
- Otherwise a positive token (PASS/PASSED/READY-TO-DEPLOY/...) in ANY field -> (True).
|
||||
- Anything else (fields set but unrecognized) -> (False, reason).
|
||||
"""
|
||||
import yaml
|
||||
|
||||
@@ -207,19 +258,25 @@ def _parse_tests_verdict(content: str) -> tuple[bool, str]:
|
||||
|
||||
verdict = str(fm.get("verdict", "") or "").upper().strip()
|
||||
status = str(fm.get("status", "") or "").upper().strip()
|
||||
result = str(fm.get("result", "") or "").upper().strip()
|
||||
|
||||
if not verdict and not status:
|
||||
return False, "No machine-readable verdict/status in test report frontmatter"
|
||||
if not verdict and not status and not result:
|
||||
return False, "No machine-readable verdict/status/result in test report frontmatter"
|
||||
|
||||
fields = f"{verdict} {status}"
|
||||
value = verdict or status or result
|
||||
fields = f"{verdict} {status} {result}"
|
||||
for neg in _TESTS_NEGATIVE_TOKENS:
|
||||
if neg in fields:
|
||||
return False, f"Test verdict: {verdict or status} ({neg})"
|
||||
return False, f"Test verdict: {value} ({neg})"
|
||||
for pos in _TESTS_POSITIVE_TOKENS:
|
||||
if pos in fields:
|
||||
return True, f"Test verdict: {verdict or status} (PASS)"
|
||||
return True, f"Test verdict: {value} (PASS)"
|
||||
|
||||
return False, f"No recognized PASS verdict in frontmatter (verdict={verdict!r}, status={status!r})"
|
||||
return (
|
||||
False,
|
||||
f"No recognized PASS verdict in frontmatter "
|
||||
f"(verdict={verdict!r}, status={status!r}, result={result!r})",
|
||||
)
|
||||
|
||||
|
||||
def check_analysis_approved(repo: str, work_item_id: str, branch: str | None = None) -> tuple[bool, str]:
|
||||
|
||||
100
tests/test_analysis_approve_flow_links.py
Normal file
100
tests/test_analysis_approve_flow_links.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""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
|
||||
284
tests/test_notify_approve_links.py
Normal file
284
tests/test_notify_approve_links.py
Normal file
@@ -0,0 +1,284 @@
|
||||
"""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&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"]
|
||||
128
tests/test_qg.py
128
tests/test_qg.py
@@ -93,38 +93,82 @@ class TestCheckArchitectureDone:
|
||||
assert passed is False
|
||||
|
||||
|
||||
def _ci_status_resp(state, status_code=200):
|
||||
"""Build a MagicMock httpx response for the Gitea combined-status endpoint."""
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = status_code
|
||||
mock_resp.json.return_value = {"state": state}
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
return mock_resp
|
||||
|
||||
|
||||
class TestCheckCIGreen:
|
||||
"""ORCH-045: check_ci_green now polls with retry to ride out a transient
|
||||
`pending` right after the developer push (race fix, see ORCH-017)."""
|
||||
|
||||
@patch("src.qg.checks.time.sleep")
|
||||
@patch("src.qg.checks.httpx.get")
|
||||
def test_ci_success(self, mock_get):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {"state": "success"}
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_get.return_value = mock_resp
|
||||
def test_ci_success_first_attempt(self, mock_get, mock_sleep):
|
||||
mock_get.return_value = _ci_status_resp("success")
|
||||
|
||||
passed, reason = check_ci_green("enduro-trails", "feature/ET-001-test")
|
||||
assert passed is True
|
||||
assert "green" in reason.lower()
|
||||
assert mock_get.call_count == 1
|
||||
mock_sleep.assert_not_called()
|
||||
|
||||
@patch("src.qg.checks.time.sleep")
|
||||
@patch("src.qg.checks.httpx.get")
|
||||
def test_ci_pending(self, mock_get):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {"state": "pending"}
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_get.return_value = mock_resp
|
||||
def test_ci_pending_then_success(self, mock_get, mock_sleep):
|
||||
# pending on the 1st poll, green on the 2nd -> success after one retry.
|
||||
mock_get.side_effect = [
|
||||
_ci_status_resp("pending"),
|
||||
_ci_status_resp("success"),
|
||||
]
|
||||
|
||||
passed, reason = check_ci_green("enduro-trails", "feature/ET-001-test")
|
||||
assert passed is True
|
||||
assert "green" in reason.lower()
|
||||
assert mock_get.call_count == 2
|
||||
assert mock_sleep.call_count == 1 # slept once between the two polls
|
||||
|
||||
@patch("src.qg.checks.time.sleep")
|
||||
@patch("src.qg.checks.httpx.get")
|
||||
def test_ci_failure_no_retry(self, mock_get, mock_sleep):
|
||||
# CI is red -> terminal, return immediately without sleeping/retrying.
|
||||
mock_get.return_value = _ci_status_resp("failure")
|
||||
|
||||
passed, reason = check_ci_green("enduro-trails", "feature/ET-001-test")
|
||||
assert passed is False
|
||||
assert "failure" in reason
|
||||
assert mock_get.call_count == 1
|
||||
mock_sleep.assert_not_called()
|
||||
|
||||
@patch("src.qg.checks.time.sleep")
|
||||
@patch("src.qg.checks.httpx.get")
|
||||
def test_ci_branch_not_found(self, mock_get):
|
||||
def test_ci_pending_exhausts_attempts(self, mock_get, mock_sleep):
|
||||
# Always pending -> after ci_poll_max_attempts polls return an explicit
|
||||
# (False, "...pending...") so the operator sees the reason (no silent stall).
|
||||
from src.qg.checks import settings as checks_settings
|
||||
|
||||
mock_get.return_value = _ci_status_resp("pending")
|
||||
|
||||
passed, reason = check_ci_green("enduro-trails", "feature/ET-001-test")
|
||||
assert passed is False
|
||||
assert "pending" in reason.lower()
|
||||
assert mock_get.call_count == checks_settings.ci_poll_max_attempts
|
||||
|
||||
@patch("src.qg.checks.time.sleep")
|
||||
@patch("src.qg.checks.httpx.get")
|
||||
def test_ci_branch_not_found(self, mock_get, mock_sleep):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 404
|
||||
mock_get.return_value = mock_resp
|
||||
|
||||
passed, reason = check_ci_green("enduro-trails", "nonexistent")
|
||||
assert passed is False
|
||||
assert "not found" in reason.lower()
|
||||
assert mock_get.call_count == 1
|
||||
|
||||
|
||||
class TestCheckReviewApproved:
|
||||
@@ -278,6 +322,64 @@ class TestCheckTestsPassed:
|
||||
assert passed is False
|
||||
assert "not found" in reason.lower()
|
||||
|
||||
# --- ORCH-047: `result:` is read as an equal-rank machine field ---
|
||||
|
||||
def test_result_pass_passes(self, setup_work_item_dir):
|
||||
# TC-01 / AC-01: canonical tester field `result: PASS` (no verdict/status).
|
||||
self._write(
|
||||
setup_work_item_dir,
|
||||
"---\ntype: test-report\nresult: PASS\n---\n\n# Test Report\n",
|
||||
)
|
||||
passed, reason = check_tests_passed("enduro-trails", "ET-001")
|
||||
assert passed is True
|
||||
assert "PASS" in reason
|
||||
|
||||
def test_result_fail_fails(self, setup_work_item_dir):
|
||||
# TC-02 / AC-02: `result: FAIL` (no verdict/status) -> rollback, reason has FAIL.
|
||||
self._write(setup_work_item_dir, "---\nresult: FAIL\n---\n\nbody\n")
|
||||
passed, reason = check_tests_passed("enduro-trails", "ET-001")
|
||||
assert passed is False
|
||||
assert "FAIL" in reason
|
||||
|
||||
def test_result_pass_but_verdict_blocked_fails(self, setup_work_item_dir):
|
||||
# TC-03 / AC-03: negative in another field is authoritative over result: PASS.
|
||||
self._write(
|
||||
setup_work_item_dir,
|
||||
"---\nresult: PASS\nverdict: BLOCKED\n---\n\n23 passed\n",
|
||||
)
|
||||
passed, reason = check_tests_passed("enduro-trails", "ET-001")
|
||||
assert passed is False
|
||||
assert "BLOCKED" in reason
|
||||
|
||||
def test_result_pass_but_status_failed_fails(self, setup_work_item_dir):
|
||||
# TC-04 / AC-03: status: failed authoritative over result: PASS.
|
||||
self._write(
|
||||
setup_work_item_dir,
|
||||
"---\nresult: PASS\nstatus: failed\n---\n\nbody\n",
|
||||
)
|
||||
passed, reason = check_tests_passed("enduro-trails", "ET-001")
|
||||
assert passed is False
|
||||
assert "FAILED" in reason
|
||||
|
||||
def test_result_ready_to_deploy_passes(self, setup_work_item_dir):
|
||||
# TC-05 / AC-04: positive token without the word PASS, in result field.
|
||||
self._write(
|
||||
setup_work_item_dir,
|
||||
"---\nresult: ready-to-deploy\n---\n\nbody\n",
|
||||
)
|
||||
passed, reason = check_tests_passed("enduro-trails", "ET-001")
|
||||
assert passed is True
|
||||
|
||||
def test_no_machine_field_reason_mentions_result(self, setup_work_item_dir):
|
||||
# AC-06: none of result/verdict/status -> fail; reason now lists result too.
|
||||
self._write(
|
||||
setup_work_item_dir,
|
||||
"---\ntype: test-report\nversion: 1\n---\n\nResult: PASS\n",
|
||||
)
|
||||
passed, reason = check_tests_passed("enduro-trails", "ET-001")
|
||||
assert passed is False
|
||||
assert "result" in reason.lower()
|
||||
|
||||
|
||||
class TestCheckDeployStatus:
|
||||
"""BUG 8: deploy -> done must be gated on the deployer's machine-readable
|
||||
|
||||
138
tests/test_resolve_agent_effort.py
Normal file
138
tests/test_resolve_agent_effort.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""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
|
||||
156
tests/test_resolve_agent_model.py
Normal file
156
tests/test_resolve_agent_model.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""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 == {}
|
||||
Reference in New Issue
Block a user