Compare commits
32 Commits
docs/lesso
...
deploy-log
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a70398496 | |||
| 9c1c028dc1 | |||
| 81e6ec5a20 | |||
| 04e88b833f | |||
| 7203812b17 | |||
| 0bc2398462 | |||
| 13b7df06b1 | |||
| b5f4eb6f2f | |||
| 75c2b814d8 | |||
| be10becae2 | |||
| 4cd55063b4 | |||
| 03c3d77cac | |||
| 29e83341b5 | |||
| c7bca51d4b | |||
| 50a3c60b0e | |||
| 615a778d20 | |||
|
|
58116f93bd | ||
| 941eec248e | |||
| b061354a8f | |||
| 5d04de9eb6 | |||
| edff0484c9 | |||
| 2f396452e8 | |||
| 185eb3f6cf | |||
| 58fc0a8b94 | |||
| c1abfb7436 | |||
| 51a76e8169 | |||
| 75fb4069a4 | |||
| c3879f2b80 | |||
| 974d4f94db | |||
| 982698c4e3 | |||
|
|
0eff781d13 | ||
| b9c61fc1f1 |
@@ -5,6 +5,8 @@
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Дословный текст findings reviewer/tester встраивается в `task_desc` заворота** (ORCH-046): при откате на `development` строка `task_desc` (попадает в `.task-dev.md` developer-агента) теперь несёт суть претензий, а не только ссылку на файл — устраняет «испорченный телефон», из-за которого агент шёл «читать файл», терял ключевые P0/P1 / причину FAIL и заворачивался снова, выжигая `MAX_DEVELOPER_RETRIES` и токены. Новый defensive-модуль `src/review_parse.py` (контракт «never raise», как `src/frontmatter.py`): `extract_review_findings(path)` — дословные пункты P0/P1 из секции `## Findings` файла `12-review.md`; `extract_test_failures(path)` — релевантный фрагмент тела `13-test-report.md` (приоритет `## Вывод pytest` → FAIL-строки `## Результаты` → `## Итог`). Обе функции усекают результат до `MAX_FINDINGS_CHARS`/`MAX_FAILURES_CHARS` (≈2000) с маркером `…(truncated)`. Две rollback-ветки `src/stage_engine.py` (reviewer REQUEST_CHANGES, tester `check_tests_passed` FAIL) встраивают извлечённый текст и **сохраняют ссылку** на полный файл («Полный контекст»); при пустом/битом артефакте — graceful-фоллбэк на прежнюю ссылку-строку (никаких исключений в `advance_stage`). Tester-ветка дополнительно всегда включает `reason` гейта. Последовательность отката, `_developer_retry_count`, поля `AdvanceResult` и реестр `QG_CHECKS` не менялись. ADR `docs/work-items/ORCH-046/06-adr/ADR-001-embed-findings-in-task-desc.md`. Тесты: `tests/test_review_parse.py`, `tests/test_stage_engine.py::TestRollbackTaskDescEmbedding`.
|
||||
- **Поллинг с ретраем в quality-gate `check_ci_green`** (ORCH-045): гейт CI превращён из single-shot в polling, чтобы устранить race condition — раньше один опрос combined commit-status сразу после пуша developer-а ловил транзиентный `pending` (типично 1-3с, реальный кейс ORCH-017: опрос 17:58:54 → pending, CI дозеленел 17:58:55) и задача застревала насмерть без повторного опроса. Теперь: `success` → пропуск сразу; `failure`/`error` → провал сразу (терминально, ретрай бессмыслен); `pending`/unknown → `time.sleep` и повторный опрос до `ci_poll_max_attempts` раз; истечение попыток → явный `(False, "CI still pending after <T>s")` (тупик больше не молчаливый); 404 → как раньше; транзиентная `httpx.HTTPError` на попытке логируется и ретраится в рамках бюджета. Параметры — новые настройки `ORCH_CI_POLL_MAX_ATTEMPTS` (12) и `ORCH_CI_POLL_INTERVAL_S` (10) в `src/config.py` (~2 мин ожидания pending). Сигнатура `check_ci_green(repo, branch)` и реестр `QG_CHECKS` не менялись; `check_tests_passed` не затронут. ADR `docs/architecture/adr/adr-0004-ci-poll-retry.md`. Тесты: `tests/test_qg.py::TestCheckCIGreen`.
|
||||
- **Прямые ссылки на BRD и Plane-таску в Telegram-уведомлении об апруве** (ORCH-017): пингующее сообщение `notify_approve_requested` теперь встраивает две HTML-`<a>`-ссылки — на `docs/work-items/<WI>/01-brd.md` (Gitea branch-view: `gitea_public_url`→`gitea_url`) и на issue в Plane (`{web_base}/{workspace}/projects/{project_id}/issues/{plane_issue_id}/`). Новая настройка `ORCH_PLANE_WEB_URL` (внешний браузерный web-URL Plane; фолбэк на `plane_api_url`). **Loopback-guard:** если итоговый Plane web-base указывает на localhost/127.0.0.1/0.0.0.0/::1 или пуст — Plane-ссылка опускается (не выпускаем битый localhost-URL). Graceful degradation: каждая ссылка строится независимо и опускается при нехватке данных, сообщение и призыв «Переведите задачу в статус Approved …» сохраняются всегда; ровно одно пингующее сообщение, разделяемая `send_telegram` не тронута. Динамические подписи экранируются `html.escape`, `parse_mode=HTML` сохранён. ADR `docs/work-items/ORCH-017/06-adr/ADR-001-telegram-approve-links.md`. Тесты: `test_notify_approve_links.py`, `test_analysis_approve_flow_links.py`.
|
||||
- **Конфигурируемые модель 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`.
|
||||
@@ -21,6 +23,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.
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
- **Webhook Receivers** (`src/webhooks/plane.py`, `gitea.py`) — приём событий, HMAC-проверка, дедупликация (`_dedup.py`). Роуты: `POST /webhook/plane`, `POST /webhook/gitea`.
|
||||
- **State Machine** (`src/stages.py`) — `STAGE_TRANSITIONS`: переходы, агент и QG каждой стадии. Хелперы: `get_next_stage`, `get_agent_for_stage`, `get_qg_for_stage`, `get_previous_stage`.
|
||||
- **Stage Engine** (`src/stage_engine.py`) — исполнение переходов, диспетчеризация QG (`_run_qg`), откаты, синхронизация с Plane.
|
||||
- **Review/Test Parsers** (`src/review_parse.py`, ORCH-046) — defensive-извлечение дословного must-fix текста из артефактов для встраивания в `task_desc` заворота: `extract_review_findings` (P0/P1 из `12-review.md`), `extract_test_failures` (фрагмент тела `13-test-report.md`). Контракт «never raise»: любая ошибка → `""`.
|
||||
- **Quality Gates** (`src/qg/checks.py`) — проверки выхода со стадии, реестр `QG_CHECKS`.
|
||||
- **Agent Launcher** (`src/agents/launcher.py`) — запуск Claude CLI агентов в изолированном git worktree, мониторинг, auto-advance.
|
||||
- **Queue** (`src/queue_worker.py`, ORCH-1) — персистентная очередь задач (SQLite `jobs`), atomic claim, max_concurrency, ретраи, restart-safe.
|
||||
@@ -46,6 +47,13 @@ created → analysis → architecture → development → review → testing →
|
||||
- Deploy / deploy-staging FAILED → откат на `development`.
|
||||
- `get_previous_stage` использует порядок ключей `STAGE_TRANSITIONS`.
|
||||
|
||||
### Обогащение `task_desc` при заворотах (ORCH-046)
|
||||
При откате на `development` `task_desc` (попадает в `.task-dev.md` developer-агента) несёт **дословный must-fix текст**, а не только ссылку — чтобы агент видел суть претензий сразу и не повторял ту же ошибку:
|
||||
- **reviewer REQUEST_CHANGES** → дословные пункты P0/P1 из секции `## Findings` файла `12-review.md` (`extract_review_findings`);
|
||||
- **tester `check_tests_passed` FAIL** → `reason` гейта + фрагмент тела `13-test-report.md` (приоритет: `## Вывод pytest` → FAIL-строки `## Результаты` → `## Итог`; `extract_test_failures`).
|
||||
|
||||
Ссылка на полный файл-артефакт сохраняется всегда («Полный контекст»). Парсеры `src/review_parse.py` — defensive (never-raise); при отсутствующем/битом артефакте `task_desc` graceful-фоллбэк на прежнюю ссылку-строку, последовательность отката и retry-счётчик не меняются (ADR `docs/work-items/ORCH-046/06-adr/ADR-001-embed-findings-in-task-desc.md`).
|
||||
|
||||
### Plane Sync: единый status-коммент агентов (ORCH-016)
|
||||
Все агенты (analyst / architect / developer / reviewer / tester / deployer) пишут финальный коммент через **один хелпер** `usage.build_status_comment(...)` (ADR `docs/work-items/ORCH-016/06-adr/ADR-001-unified-status-comment.md`). Формат HTML, разделители `<br>`:
|
||||
|
||||
@@ -58,7 +66,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`.
|
||||
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).
|
||||
7
docs/work-items/ORCH-046/00-business-request.md
Normal file
7
docs/work-items/ORCH-046/00-business-request.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Business Request: stage_engine: pass reviewer/tester findings text to developer (not just link)
|
||||
|
||||
Work Item ID: ORCH-046
|
||||
|
||||
## Description
|
||||
|
||||
TBD
|
||||
86
docs/work-items/ORCH-046/01-brd.md
Normal file
86
docs/work-items/ORCH-046/01-brd.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# BRD — ORCH-046: pass reviewer/tester findings text to developer (not just link)
|
||||
|
||||
Work Item ID: ORCH-046
|
||||
Stage: analysis
|
||||
Author: analyst
|
||||
Date: 2026-06-06
|
||||
|
||||
## 1. Контекст и проблема
|
||||
|
||||
Оркестратор при заворотах задачи деву (откат на `development`) формирует
|
||||
описание задачи (`task_desc`), которое попадает в `.task-dev.md` запускаемого
|
||||
агента-разработчика. Сейчас в двух ветках отката этот текст содержит **только
|
||||
ссылку на файл-артефакт**, без сути замечаний:
|
||||
|
||||
- **Reviewer → REQUEST_CHANGES** (`src/stage_engine.py`, ветка
|
||||
`_handle_qg_failure_rollbacks`, ~стр. 419): `task_desc` =
|
||||
`"…Fix findings in docs/work-items/<id>/12-review.md"`.
|
||||
- **Tester → FAIL** (`check_tests_passed`, ~стр. 455): `task_desc` =
|
||||
`"…Fix failures described in docs/work-items/<id>/13-test-report.md"`.
|
||||
|
||||
В результате developer-агент получает инструкцию «иди читай файл». Ключевые
|
||||
претензии (P0/P1 у ревьюера, причина падения у тестера) часто проскакивают —
|
||||
агент не открывает файл целиком или теряет фокус, повторяет ту же ошибку, и
|
||||
задача снова заворачивается. Это «испорченный телефон»: расход циклов retry
|
||||
(`MAX_DEVELOPER_RETRIES = 3`), деньги на токены, простой конвейера.
|
||||
|
||||
## 2. Бизнес-цель
|
||||
|
||||
Убрать «испорченный телефон» между reviewer/tester и developer при заворотах:
|
||||
встраивать **дословный текст ключевых замечаний** прямо в `task_desc`, чтобы
|
||||
developer-агент видел суть претензий сразу, а не только ссылку.
|
||||
|
||||
Это снижает число повторных заворотов и расход retry-бюджета на одну задачу.
|
||||
|
||||
## 3. Объём (вариант A — выбран Славой 06.06)
|
||||
|
||||
Минимальное, низкорисковое изменение **ядра** (`stage_engine`), которое:
|
||||
|
||||
1. Извлекает из `12-review.md` блок findings и выносит **must-fix (P0/P1)
|
||||
дословно** в `task_desc` при reviewer REQUEST_CHANGES.
|
||||
2. Извлекает из `13-test-report.md` причину FAIL (reason из гейта + релевантный
|
||||
фрагмент тела отчёта) в `task_desc` при tester FAIL.
|
||||
3. Во всех случаях **сохраняет ссылку на полный файл** как дополнительный
|
||||
контекст («полный контекст — см. файл»).
|
||||
4. Извлечение выполняется новым отдельным хелпером-парсером
|
||||
(`src/review_parse.py`), который **никогда не бросает исключение**: при
|
||||
отсутствующем/битом файле возвращает пустой результат, и вызывающий код
|
||||
делает graceful fallback на прежнюю ссылку-строку.
|
||||
|
||||
## 4. Что НЕ входит в объём (out of scope)
|
||||
|
||||
- НЕ трогать гейты `check_*` (в т. ч. ORCH-45 `check_ci_green`,
|
||||
ORCH-47 `_parse_tests_verdict`) — они в проде, поведение неизменно.
|
||||
- НЕ трогать реестр `QG_CHECKS`.
|
||||
- НЕ менять сигнатуры публичных функций (`advance_stage`, `_run_qg`,
|
||||
`check_*`).
|
||||
- НЕ менять webhook-пути.
|
||||
- НЕ менять retry-счётчик (`_developer_retry_count`, `MAX_DEVELOPER_RETRIES`)
|
||||
и rollback-логику (последовательность `update_task_stage` →
|
||||
`notify_stage_change` → `plane_notify_stage` → enqueue) — поведение
|
||||
идентично.
|
||||
- НЕ менять формат Plane-комментариев (`build_status_comment`).
|
||||
|
||||
## 5. Заинтересованные стороны
|
||||
|
||||
- **Owner (Слава)** — заказчик, выбрал вариант A.
|
||||
- **Developer-агенты** — потребители `task_desc`: получают суть замечаний.
|
||||
- **Конвейер всех проектов** (self-hosting) — выигрывает за счёт меньшего
|
||||
числа заворотов.
|
||||
|
||||
## 6. Ограничения и риски (self-hosting)
|
||||
|
||||
- Правка ядра `stage_engine` — компонент крутится в продакшене и обслуживает
|
||||
все проекты из общего инстанса/БД/очереди. Любая регрессия в формировании
|
||||
`task_desc` или (тем более) исключение в `advance_stage` останавливает
|
||||
конвейер всех проектов → **парсер обязан быть полностью graceful**.
|
||||
- Обязателен прогон `deploy-staging` (8501) перед прод-деплоем.
|
||||
- Это правка ядра → требуется ADR (per-work-item).
|
||||
|
||||
## 7. Критерий успеха (бизнес)
|
||||
|
||||
- При заворотах в `.task-dev.md` есть дословный текст ключевых замечаний
|
||||
(P0/P1 ревьюера; reason+фрагмент тестера) плюс ссылка на полный файл.
|
||||
- Парсер устойчив к битым/отсутствующим артефактам (graceful fallback на
|
||||
старую ссылку-строку).
|
||||
- Существующие тесты зелёные; поведение retry/rollback не изменилось.
|
||||
209
docs/work-items/ORCH-046/02-trz.md
Normal file
209
docs/work-items/ORCH-046/02-trz.md
Normal file
@@ -0,0 +1,209 @@
|
||||
# ТЗ — ORCH-046: встраивание текста findings reviewer/tester в task_desc
|
||||
|
||||
Work Item ID: ORCH-046
|
||||
Stage: analysis
|
||||
Author: analyst
|
||||
Date: 2026-06-06
|
||||
|
||||
> Вариант A (минимальный, низкий риск). Это правка ЯДРА — обязателен ADR
|
||||
> (per-work-item, `docs/work-items/ORCH-046/06-adr/`).
|
||||
|
||||
## 1. Задействованные модули `src/`
|
||||
|
||||
| Модуль | Изменение |
|
||||
|--------|-----------|
|
||||
| `src/review_parse.py` | **НОВЫЙ** хелпер-парсер: `extract_review_findings(path) -> str`, `extract_test_failures(path) -> str`. |
|
||||
| `src/stage_engine.py` | Две ветки в `_handle_qg_failure_rollbacks`: reviewer REQUEST_CHANGES (~стр. 419) и tester `check_tests_passed` FAIL (~стр. 455) — встраивают извлечённый текст в `task_desc`. |
|
||||
|
||||
Источники-образцы (не менять, использовать как референс паттерна «never raise» и
|
||||
формата артефактов):
|
||||
- `src/qg/checks.py::_parse_tests_verdict` — образец «never raise», split по `---`, `yaml.safe_load`.
|
||||
- `src/frontmatter.py::read_frontmatter_value` — образец defensive-парсера.
|
||||
- `.openclaw/agents/reviewer.md` — канонический формат `12-review.md`.
|
||||
- `.openclaw/agents/tester.md` — канонический формат `13-test-report.md`.
|
||||
|
||||
## 2. Новый модуль `src/review_parse.py`
|
||||
|
||||
### 2.1. `extract_review_findings(path: str) -> str`
|
||||
|
||||
Назначение: вернуть **дословный** текст must-fix findings (P0 + P1) из
|
||||
`12-review.md` для встраивания в `task_desc`.
|
||||
|
||||
Формат входного файла (канон reviewer.md, секция `## Findings`):
|
||||
|
||||
```markdown
|
||||
## Findings
|
||||
|
||||
### P0 — Blocker
|
||||
- [ ] <описание>
|
||||
|
||||
### P1 — Must fix
|
||||
- [ ] <описание>
|
||||
|
||||
### P2 — Should fix
|
||||
- [ ] <описание>
|
||||
```
|
||||
|
||||
Требования к реализации:
|
||||
|
||||
1. **Никогда не бросает исключение.** Любая ошибка (нет файла, IOError, кривой
|
||||
markdown, нет секции `## Findings`) → возврат `""` (пустая строка).
|
||||
2. Парсит **только** подсекции P0 и P1 (must-fix). P2/P3 игнорируются.
|
||||
3. Заголовки подсекций распознаются устойчиво к регистру и к тире/дефису:
|
||||
соответствие по наличию токена `P0` / `P1` в строке-заголовке уровня `###`.
|
||||
4. Из распознанных подсекций берётся текст до следующего заголовка `###`/`##`
|
||||
(т. е. тело подсекции дословно: пункты списка `- [ ] …` / `- …`).
|
||||
5. Пустые подсекции (нет содержательных пунктов, только `(если есть)`-плейсхолдер
|
||||
или ничего) — пропускаются. Если ни одного содержательного P0/P1 пункта нет
|
||||
→ возврат `""`.
|
||||
6. Результат — компактный многострочный текст, пригодный для вставки в
|
||||
`task_desc` (например, заголовок подсекции + её пункты). Длина результата
|
||||
ограничивается разумным лимитом (`MAX_FINDINGS_CHARS`, напр. 2000) с
|
||||
усечением и маркером `…(truncated)`; полный контекст всё равно остаётся в
|
||||
файле.
|
||||
7. Frontmatter (верхний `--- … ---`) при необходимости отбрасывается, чтобы не
|
||||
попасть в тело; парсинг секции делается по телу markdown.
|
||||
|
||||
Сигнатура и контракт (стабильны):
|
||||
```python
|
||||
def extract_review_findings(path: str) -> str:
|
||||
"""Дословный текст P0/P1 findings из 12-review.md. Never raises; '' при ошибке/пусто."""
|
||||
```
|
||||
|
||||
### 2.2. `extract_test_failures(path: str) -> str`
|
||||
|
||||
Назначение: вернуть текст причины падения тестов из `13-test-report.md` для
|
||||
встраивания в `task_desc`.
|
||||
|
||||
Формат входного файла (канон tester.md): frontmatter `result: PASS|FAIL`, далее
|
||||
тело с секциями `## Результаты` (таблица TC), `## Вывод pytest`, `## Итог`.
|
||||
|
||||
Требования к реализации:
|
||||
|
||||
1. **Никогда не бросает исключение.** Любая ошибка → возврат `""`.
|
||||
2. Извлекает релевантный фрагмент тела, помогающий понять причину FAIL.
|
||||
Приоритет источников (берём первый непустой):
|
||||
- секция `## Вывод pytest` (вывод прогона — где видно упавшие тесты), и/или
|
||||
- строки таблицы `## Результаты`, содержащие `FAIL`, и/или
|
||||
- секция `## Итог`.
|
||||
3. Результат усекается до `MAX_FAILURES_CHARS` (напр. 2000) с маркером
|
||||
`…(truncated)`.
|
||||
4. Если ничего извлечь не удалось → возврат `""` (вызывающий код делает
|
||||
fallback на ссылку).
|
||||
|
||||
> Примечание: «reason» из самого гейта (`check_tests_passed` → второй элемент
|
||||
> кортежа) у вызывающего кода уже есть (`reason`) — он добавляется в `task_desc`
|
||||
> вызывающим кодом (как и сейчас в комментарии тестера). `extract_test_failures`
|
||||
> добавляет **фрагмент тела отчёта** поверх этого reason.
|
||||
|
||||
Сигнатура и контракт (стабильны):
|
||||
```python
|
||||
def extract_test_failures(path: str) -> str:
|
||||
"""Релевантный фрагмент тела 13-test-report.md (причина FAIL). Never raises; '' при ошибке/пусто."""
|
||||
```
|
||||
|
||||
### 2.3. Общие требования модуля
|
||||
|
||||
- Модуль логирует диагностические сообщения на уровне `logger.debug`
|
||||
(`logging.getLogger("orchestrator.review_parse")`), как `frontmatter.py`.
|
||||
- Никаких сетевых вызовов, только чтение файла с диска.
|
||||
- Константы лимитов вынесены модульными (`MAX_FINDINGS_CHARS`,
|
||||
`MAX_FAILURES_CHARS`).
|
||||
|
||||
## 3. Изменения `src/stage_engine.py`
|
||||
|
||||
### 3.1. Ветка reviewer REQUEST_CHANGES (внутри `_handle_qg_failure_rollbacks`)
|
||||
|
||||
Текущее (~стр. 418–424):
|
||||
```python
|
||||
task_desc = (
|
||||
f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n"
|
||||
f"Stage: development\nNote: REQUEST_CHANGES from reviewer "
|
||||
f"(attempt {retry_count+1}/3). Fix findings in "
|
||||
f"docs/work-items/{work_item_id}/12-review.md"
|
||||
)
|
||||
```
|
||||
|
||||
Целевое поведение:
|
||||
- Сформировать путь к `12-review.md` через `get_worktree_path(repo, branch)` +
|
||||
`docs/work-items/{work_item_id}/12-review.md` (как в `_check_review_approved_by_branch`).
|
||||
- Вызвать `extract_review_findings(path)`.
|
||||
- Если результат непустой — встроить findings **дословно** в `task_desc`
|
||||
(под подзаголовком, напр. `Findings (P0/P1):\n<text>`), а ссылку на файл
|
||||
оставить как «полный контекст» (`Полный контекст: docs/work-items/<id>/12-review.md`).
|
||||
- Если результат пустой (graceful fallback) — `task_desc` остаётся **как
|
||||
сейчас** (ссылка-строка). Никаких исключений.
|
||||
- Префиксная часть (`Work item / Repo / Branch / Stage / Note: REQUEST_CHANGES …
|
||||
(attempt N/3)`) сохраняется без изменений.
|
||||
|
||||
### 3.2. Ветка tester FAIL (`check_tests_passed`, внутри `_handle_qg_failure_rollbacks`)
|
||||
|
||||
Текущее (~стр. 454–459):
|
||||
```python
|
||||
task_desc = (
|
||||
f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n"
|
||||
f"Stage: development\nNote: Tests FAILED. "
|
||||
f"Fix failures described in docs/work-items/{work_item_id}/13-test-report.md"
|
||||
)
|
||||
```
|
||||
|
||||
Целевое поведение:
|
||||
- Сформировать путь к `13-test-report.md` аналогично.
|
||||
- Вызвать `extract_test_failures(path)`.
|
||||
- В `task_desc` всегда включить `reason` (он уже доступен в этой ветке —
|
||||
передаётся в `_handle_qg_failure_rollbacks`).
|
||||
- Если фрагмент тела непустой — встроить его дословно
|
||||
(`Причина: {reason}\nДетали:\n<fragment>`), плюс ссылку на файл как полный
|
||||
контекст.
|
||||
- Если фрагмент пустой — `task_desc` содержит `reason` + ссылку (graceful
|
||||
fallback, не хуже текущего поведения). Никаких исключений.
|
||||
- Префиксная часть и существующий Plane-комментарий тестера
|
||||
(`❌ Тесты не прошли: {reason}…`) НЕ меняются.
|
||||
|
||||
### 3.3. Инварианты (НЕ менять поведение)
|
||||
|
||||
- Последовательность rollback в обеих ветках: `update_task_stage(task_id,
|
||||
"development")` → `notify_stage_change` → `plane_notify_stage` →
|
||||
(`set_issue_in_progress` для тестера) → проверка `_developer_retry_count` <
|
||||
`MAX_DEVELOPER_RETRIES` → `enqueue_job("developer", …)` либо
|
||||
`send_telegram` alert. Порядок и условия идентичны.
|
||||
- `result.rolled_back_to`, `result.enqueued_agent`, `result.enqueued_job_id`,
|
||||
`result.alerted` выставляются как сейчас.
|
||||
- Меняется **только содержимое строки `task_desc`**, передаваемой в
|
||||
`enqueue_job`.
|
||||
- Импорт нового модуля — `from .review_parse import extract_review_findings,
|
||||
extract_test_failures` в шапке `stage_engine.py`.
|
||||
|
||||
## 4. Изменения API
|
||||
|
||||
Нет. Публичные HTTP-эндпоинты (`/health`, `/status`, `/queue`,
|
||||
`/webhook/plane`, `/webhook/gitea`) не затрагиваются.
|
||||
|
||||
## 5. Изменения схемы БД
|
||||
|
||||
Нет. Таблицы `tasks`, `agent_runs`, `jobs`, `events` не меняются.
|
||||
`enqueue_job` вызывается с прежней сигнатурой.
|
||||
|
||||
## 6. Требования к новым QG checks
|
||||
|
||||
Нет. Реестр `QG_CHECKS` и все `check_*` не трогаются (явно out of scope).
|
||||
|
||||
## 7. Артефакты pipeline (создать/обновить в этом PR)
|
||||
|
||||
- `src/review_parse.py` — новый модуль.
|
||||
- `tests/test_review_parse.py` — юнит-тесты парсера (см. 04-test-plan.yaml).
|
||||
- Возможные дополнения в `tests/test_stage_engine.py` — проверка встраивания
|
||||
текста в `task_desc` (rollback-ветки).
|
||||
- `docs/work-items/ORCH-046/06-adr/ADR-001-*.md` — ADR (правка ядра).
|
||||
- `docs/architecture/README.md` / `internals.md` — описание нового хелпера и
|
||||
поведения заворотов (если reviewer сочтёт необходимым; компонент описать в
|
||||
разделе Stage Engine / Откаты).
|
||||
- `CHANGELOG.md` — запись о ORCH-046.
|
||||
|
||||
## 8. Контроль качества / проверка
|
||||
|
||||
```bash
|
||||
python -m pytest tests/ -q # в контейнере; все тесты зелёные
|
||||
```
|
||||
|
||||
Обязательно: стадия `deploy-staging` (8501) перед прод-деплоем (self-hosting).
|
||||
99
docs/work-items/ORCH-046/03-acceptance-criteria.md
Normal file
99
docs/work-items/ORCH-046/03-acceptance-criteria.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# Критерии приёмки — ORCH-046
|
||||
|
||||
Work Item ID: ORCH-046
|
||||
Stage: analysis
|
||||
Author: analyst
|
||||
Date: 2026-06-06
|
||||
|
||||
Каждый критерий имеет чёткое условие PASS/FAIL. Reviewer/Tester проверяют по
|
||||
этому списку.
|
||||
|
||||
## AC-1 — Дословные P0/P1 findings ревьюера в task_desc
|
||||
|
||||
**Условие:** при reviewer REQUEST_CHANGES (откат `review`/`testing` →
|
||||
`development`) строка `task_desc`, переданная в `enqueue_job("developer", …)`,
|
||||
содержит ДОСЛОВНЫЙ текст findings уровня P0/P1 из `12-review.md` (не только
|
||||
ссылку).
|
||||
|
||||
- **PASS:** в `task_desc` присутствуют дословные строки P0/P1 пунктов из секции
|
||||
`## Findings` файла `12-review.md`.
|
||||
- **FAIL:** `task_desc` содержит только ссылку на файл, без текста findings (при
|
||||
наличии валидного файла с P0/P1).
|
||||
|
||||
## AC-2 — Причина падения тестера в task_desc
|
||||
|
||||
**Условие:** при tester FAIL (`check_tests_passed`, откат `testing` →
|
||||
`development`) строка `task_desc` содержит причину падения: `reason` из гейта +
|
||||
релевантный фрагмент тела `13-test-report.md`.
|
||||
|
||||
- **PASS:** `task_desc` содержит `reason` И непустой фрагмент тела отчёта
|
||||
(вывод pytest / FAIL-строки / Итог), когда отчёт валиден.
|
||||
- **FAIL:** `task_desc` содержит только ссылку на файл без причины/фрагмента
|
||||
(при наличии валидного отчёта).
|
||||
|
||||
## AC-3 — Ссылка на полный файл сохранена
|
||||
|
||||
**Условие:** в обеих ветках (reviewer, tester) `task_desc` по-прежнему содержит
|
||||
ссылку на полный файл-артефакт (`docs/work-items/<id>/12-review.md` /
|
||||
`13-test-report.md`) как дополнительный контекст.
|
||||
|
||||
- **PASS:** путь к файлу присутствует в `task_desc` в обоих сценариях.
|
||||
- **FAIL:** ссылка на файл удалена/отсутствует.
|
||||
|
||||
## AC-4 — Парсер устойчив к отсутствию/битому файлу (graceful)
|
||||
|
||||
**Условие:** `extract_review_findings(path)` и `extract_test_failures(path)`
|
||||
НИКОГДА не бросают исключение; при отсутствующем/нечитаемом/битом файле
|
||||
возвращают `""`, а вызывающий код в `stage_engine` делает fallback на прежнюю
|
||||
ссылку-строку.
|
||||
|
||||
- **PASS:** на несуществующем пути, пустом файле, файле без секций, битом
|
||||
markdown/YAML — функции возвращают `""` без исключения; `advance_stage`
|
||||
отрабатывает откат как раньше (ссылка-строка в `task_desc`).
|
||||
- **FAIL:** любое исключение наружу из парсера или из `advance_stage` из-за
|
||||
парсинга.
|
||||
|
||||
## AC-5 — Тесты зелёные + новые юнит-тесты парсера
|
||||
|
||||
**Условие:** существующие тесты не сломаны; добавлены юнит-тесты парсера,
|
||||
покрывающие: findings есть / findings пусто / битый YAML(frontmatter) / только
|
||||
P3 (нет P0/P1).
|
||||
|
||||
- **PASS:** `python -m pytest tests/ -q` зелёный; `tests/test_review_parse.py`
|
||||
содержит как минимум кейсы: P0/P1 присутствуют → текст возвращён; нет
|
||||
findings/только P2-P3 → `""`; битый файл → `""`; отсутствующий путь → `""`;
|
||||
для test-report: FAIL-фрагмент извлечён / пустой отчёт → `""`.
|
||||
- **FAIL:** падение существующих тестов или отсутствие перечисленных кейсов.
|
||||
|
||||
## AC-6 — Retry-счётчик и rollback НЕ изменены по поведению
|
||||
|
||||
**Условие:** логика `_developer_retry_count`, `MAX_DEVELOPER_RETRIES = 3`,
|
||||
последовательность откатов и поля `AdvanceResult` (`rolled_back_to`,
|
||||
`enqueued_agent`, `enqueued_job_id`, `alerted`) идентичны прежним.
|
||||
|
||||
- **PASS:** существующие тесты `test_stage_engine.py` на rollback/retry зелёные;
|
||||
при 4-м заходе по-прежнему alert вместо enqueue; меняется только текст
|
||||
`task_desc`.
|
||||
- **FAIL:** изменилось число retry, порядок вызовов, или значения полей
|
||||
`AdvanceResult`.
|
||||
|
||||
## AC-7 — Out-of-scope не затронут
|
||||
|
||||
**Условие:** не изменены: `check_*` гейты, реестр `QG_CHECKS`, сигнатуры
|
||||
публичных функций (`advance_stage`, `_run_qg`, `check_*`), webhook-пути, формат
|
||||
Plane-комментариев.
|
||||
|
||||
- **PASS:** `git diff` не содержит изменений в `src/qg/checks.py` (логика
|
||||
гейтов), сигнатурах публичных функций, `src/webhooks/*`,
|
||||
`usage.build_status_comment`; `test_qg_registry_snapshot` зелёный.
|
||||
- **FAIL:** любое из перечисленного изменено.
|
||||
|
||||
## AC-8 — Документация и ADR обновлены (golden source)
|
||||
|
||||
**Условие:** правка ядра → заведён ADR (`06-adr/`), обновлён `CHANGELOG.md`, при
|
||||
необходимости — `docs/architecture/README.md`/`internals.md` (раздел Stage
|
||||
Engine / Откаты).
|
||||
|
||||
- **PASS:** присутствует `docs/work-items/ORCH-046/06-adr/ADR-001-*.md`; в
|
||||
`CHANGELOG.md` есть запись ORCH-046.
|
||||
- **FAIL:** ADR или запись в CHANGELOG отсутствуют.
|
||||
108
docs/work-items/ORCH-046/04-test-plan.yaml
Normal file
108
docs/work-items/ORCH-046/04-test-plan.yaml
Normal file
@@ -0,0 +1,108 @@
|
||||
work_item: ORCH-046
|
||||
description: >
|
||||
Тест-план для встраивания дословного текста findings reviewer/tester в
|
||||
task_desc при заворотах деву. Покрывает новый парсер src/review_parse.py
|
||||
(graceful, never-raise) и две rollback-ветки src/stage_engine.py.
|
||||
|
||||
tests:
|
||||
# --- Парсер review findings (extract_review_findings) -------------------
|
||||
- id: TC-01
|
||||
type: unit
|
||||
description: "extract_review_findings возвращает дословный текст P0/P1 при их наличии в 12-review.md"
|
||||
module: tests/test_review_parse.py
|
||||
covers: [AC-1, AC-5]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-02
|
||||
type: unit
|
||||
description: "extract_review_findings возвращает '' когда есть только P2/P3 (нет must-fix P0/P1)"
|
||||
module: tests/test_review_parse.py
|
||||
covers: [AC-5]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-03
|
||||
type: unit
|
||||
description: "extract_review_findings возвращает '' для отсутствующего файла (несуществующий путь), без исключения"
|
||||
module: tests/test_review_parse.py
|
||||
covers: [AC-4]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-04
|
||||
type: unit
|
||||
description: "extract_review_findings возвращает '' для битого/пустого файла или markdown без секции ## Findings, без исключения"
|
||||
module: tests/test_review_parse.py
|
||||
covers: [AC-4, AC-5]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-05
|
||||
type: unit
|
||||
description: "extract_review_findings усекает очень длинные findings до лимита с маркером truncated"
|
||||
module: tests/test_review_parse.py
|
||||
covers: [AC-1]
|
||||
expected: PASS
|
||||
|
||||
# --- Парсер test failures (extract_test_failures) ----------------------
|
||||
- id: TC-06
|
||||
type: unit
|
||||
description: "extract_test_failures извлекает релевантный фрагмент тела (Вывод pytest / FAIL-строки / Итог) из 13-test-report.md с result: FAIL"
|
||||
module: tests/test_review_parse.py
|
||||
covers: [AC-2, AC-5]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-07
|
||||
type: unit
|
||||
description: "extract_test_failures возвращает '' для отсутствующего файла, без исключения"
|
||||
module: tests/test_review_parse.py
|
||||
covers: [AC-4]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-08
|
||||
type: unit
|
||||
description: "extract_test_failures возвращает '' для битого/пустого отчёта (нет тела/секций), без исключения"
|
||||
module: tests/test_review_parse.py
|
||||
covers: [AC-4, AC-5]
|
||||
expected: PASS
|
||||
|
||||
# --- Интеграция со stage_engine (rollback task_desc) -------------------
|
||||
- id: TC-09
|
||||
type: integration
|
||||
description: "advance_stage: reviewer REQUEST_CHANGES -> в enqueue_job('developer') task_desc содержит дословные P0/P1 findings И ссылку на 12-review.md"
|
||||
module: tests/test_stage_engine.py
|
||||
covers: [AC-1, AC-3]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-10
|
||||
type: integration
|
||||
description: "advance_stage: tester check_tests_passed FAIL -> task_desc содержит reason + фрагмент 13-test-report.md И ссылку на файл"
|
||||
module: tests/test_stage_engine.py
|
||||
covers: [AC-2, AC-3]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-11
|
||||
type: integration
|
||||
description: "advance_stage: reviewer REQUEST_CHANGES при отсутствующем/битом 12-review.md -> graceful fallback, task_desc = прежняя ссылка-строка, без исключения"
|
||||
module: tests/test_stage_engine.py
|
||||
covers: [AC-4, AC-3]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-12
|
||||
type: integration
|
||||
description: "advance_stage: rollback/retry поведение неизменно — последовательность откатов, _developer_retry_count, alert на 4-й заход, поля AdvanceResult"
|
||||
module: tests/test_stage_engine.py
|
||||
covers: [AC-6]
|
||||
expected: PASS
|
||||
|
||||
# --- Регресс / неизменность out-of-scope ------------------------------
|
||||
- id: TC-13
|
||||
type: integration
|
||||
description: "Реестр QG_CHECKS не изменён (snapshot), гейты check_* нетронуты"
|
||||
module: tests/test_qg_registry_snapshot.py
|
||||
covers: [AC-7]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-14
|
||||
type: integration
|
||||
description: "Полный регресс существующего набора зелёный: python -m pytest tests/ -q"
|
||||
module: tests/
|
||||
covers: [AC-5, AC-6, AC-7]
|
||||
expected: PASS
|
||||
@@ -0,0 +1,143 @@
|
||||
# ADR-001: дословный текст findings reviewer/tester встраивается в `task_desc` через отдельный graceful-парсер
|
||||
|
||||
- **Статус:** Accepted
|
||||
- **Дата:** 2026-06-06
|
||||
- **Задача:** ORCH-046
|
||||
- **Область:** ЯДРО `src/stage_engine.py` (rollback-ветки) + новый модуль `src/review_parse.py`. Общий прод-инстанс (orchestrator + enduro-trails), self-hosting.
|
||||
|
||||
## Контекст
|
||||
|
||||
При заворотах задачи на `development` (откат) `stage_engine` формирует `task_desc`,
|
||||
который попадает в `.task-dev.md` запускаемого developer-агента. В двух ветках
|
||||
`_handle_qg_failure_rollbacks` этот текст содержит **только ссылку на файл-артефакт**:
|
||||
|
||||
- reviewer REQUEST_CHANGES (`src/stage_engine.py` ~стр. 419) → `…Fix findings in docs/work-items/<id>/12-review.md`;
|
||||
- tester `check_tests_passed` FAIL (~стр. 455) → `…Fix failures described in docs/work-items/<id>/13-test-report.md`.
|
||||
|
||||
Developer-агент получает инструкцию «иди читай файл»; ключевые претензии (P0/P1
|
||||
ревьюера, причина падения тестера) теряются — агент повторяет ту же ошибку, и
|
||||
задача заворачивается снова. Это «испорченный телефон»: расход retry-бюджета
|
||||
(`MAX_DEVELOPER_RETRIES = 3`), токенов и простой конвейера (для всех проектов
|
||||
общего инстанса).
|
||||
|
||||
Ограничение из BRD/ТЗ (вариант A, выбран Owner): минимальная, низкорисковая
|
||||
правка ядра. Любая регрессия в формировании `task_desc` или (тем более)
|
||||
исключение в `advance_stage` останавливает конвейер ВСЕХ проектов — следовательно
|
||||
извлечение текста обязано быть полностью graceful.
|
||||
|
||||
## Решение
|
||||
|
||||
Встраивать **дословный текст ключевых замечаний** в `task_desc` при заворотах,
|
||||
сохраняя ссылку на полный файл как дополнительный контекст. Извлечение вынести в
|
||||
отдельный defensive-модуль, чтобы изолировать blast radius от ядра.
|
||||
|
||||
1. **Новый модуль `src/review_parse.py`** с двумя чистыми функциями чтения с диска:
|
||||
- `extract_review_findings(path: str) -> str` — дословные пункты P0/P1 из секции
|
||||
`## Findings` файла `12-review.md`;
|
||||
- `extract_test_failures(path: str) -> str` — релевантный фрагмент тела
|
||||
`13-test-report.md` (приоритет: `## Вывод pytest` → FAIL-строки `## Результаты`
|
||||
→ `## Итог`).
|
||||
- **Контракт «never raise»** (как `src/frontmatter.py` и
|
||||
`src/qg/checks.py::_parse_tests_verdict`): любая ошибка — нет файла, IOError,
|
||||
кривой markdown/YAML, нет секции — возвращает `""`. Логирование на
|
||||
`logger.debug` (`logging.getLogger("orchestrator.review_parse")`). Никаких
|
||||
сетевых вызовов.
|
||||
- Результат усекается модульными лимитами `MAX_FINDINGS_CHARS`,
|
||||
`MAX_FAILURES_CHARS` (≈2000) с маркером `…(truncated)`; полный контекст всегда
|
||||
остаётся в файле.
|
||||
|
||||
2. **Две ветки `_handle_qg_failure_rollbacks` в `src/stage_engine.py`** строят путь
|
||||
через `get_worktree_path(repo, branch)` (как `_check_review_approved_by_branch`),
|
||||
вызывают соответствующий парсер и:
|
||||
- если результат непустой — встраивают findings/фрагмент **дословно** под
|
||||
подзаголовком + оставляют ссылку как «полный контекст»;
|
||||
- если результат пустой — `task_desc` остаётся **как сейчас** (graceful fallback
|
||||
на ссылку-строку);
|
||||
- tester-ветка дополнительно всегда включает `reason` из гейта (он уже доступен).
|
||||
|
||||
3. **Изоляция ядра.** Меняется ТОЛЬКО содержимое строки `task_desc`, передаваемой в
|
||||
`enqueue_job`. Последовательность отката (`update_task_stage` →
|
||||
`notify_stage_change` → `plane_notify_stage` → [`set_issue_in_progress` для
|
||||
тестера] → проверка `_developer_retry_count` < `MAX_DEVELOPER_RETRIES` →
|
||||
`enqueue_job`/`send_telegram`), значения `AdvanceResult` (`rolled_back_to`,
|
||||
`enqueued_agent`, `enqueued_job_id`, `alerted`) и Plane-комментарии — без
|
||||
изменений.
|
||||
|
||||
### Почему отдельный модуль, а не inline в `stage_engine`
|
||||
|
||||
- Тестируемость: парсер покрывается юнит-тестами `tests/test_review_parse.py`
|
||||
изолированно от тяжёлого `advance_stage`.
|
||||
- Blast radius: вся парсинг-логика (и её исключения) физически отделена от
|
||||
hot-path ядра; ядро только подставляет строку и делает try-around-граничный
|
||||
fallback.
|
||||
- Согласованность с уже принятым паттерном defensive-парсеров
|
||||
(`frontmatter.py`).
|
||||
|
||||
### Почему НЕ переиспользуется `frontmatter.read_frontmatter_value`
|
||||
|
||||
Тот хелпер читает одиночное значение из YAML-frontmatter. Здесь нужно извлекать
|
||||
**тело markdown** (подсекции `## Findings`/`### P0`, фрагменты `## Вывод pytest`),
|
||||
а не frontmatter-ключ. Это другая задача парсинга; общий контракт «never raise»
|
||||
повторяется намеренно (как уже зафиксировано в ORCH-016/ADR-001 §5 — слияние
|
||||
парсеров отдельной задачей).
|
||||
|
||||
### Почему per-work-item ADR, а не глобальный
|
||||
|
||||
Изменение НЕ добавляет гейт/стадию/компонент и НЕ меняет топологию или реестр
|
||||
`QG_CHECKS` — это обогащение содержимого `task_desc` в существующих rollback-ветках
|
||||
плюс вспомогательный модуль. По прецеденту ORCH-047/ADR-001 такого класса правки
|
||||
фиксируются per-work-item ADR. Глобальный `docs/architecture/adr/` не требуется.
|
||||
|
||||
### Альтернативы (отклонены)
|
||||
|
||||
- **Inline-парсинг прямо в `stage_engine`** — отклонено: раздувает ядро, хуже
|
||||
тестируется, исключения ближе к hot-path.
|
||||
- **Менять промпты reviewer/tester, чтобы они сами клали суть в `task_desc`** —
|
||||
отклонено: `task_desc` формирует ядро, а не агент; зависит от дисциплины двух
|
||||
агентов вместо детерминированного кода; шире поверхность регрессии.
|
||||
- **Передавать весь файл целиком в `task_desc`** — отклонено: раздувает промпт
|
||||
developer-агента и стоимость токенов; теряется фокус на must-fix. Усечение по
|
||||
P0/P1 + лимит решает проблему «испорченного телефона» дешевле.
|
||||
|
||||
## Последствия
|
||||
|
||||
- **Плюс:** developer-агент видит суть претензий (P0/P1, причина FAIL) сразу в
|
||||
`.task-dev.md`; меньше повторных заворотов, экономия retry-бюджета и токенов на
|
||||
всех проектах общего инстанса.
|
||||
- **Плюс:** при битом/отсутствующем артефакте поведение не хуже текущего (ссылка
|
||||
сохраняется); ссылка на полный файл присутствует всегда (AC-3).
|
||||
- **Плюс:** изменение аддитивное — публичные сигнатуры (`advance_stage`, `_run_qg`,
|
||||
`check_*`), реестр `QG_CHECKS`, webhook-пути и `build_status_comment` не
|
||||
затрагиваются; снапшот `test_qg_registry_snapshot` остаётся зелёным (AC-7).
|
||||
- **Минус/ограничение:** парсинг тела markdown чувствительнее к формату артефактов,
|
||||
чем чтение одного frontmatter-ключа. Митигировано: распознавание P0/P1 устойчиво
|
||||
к регистру/тире; при несовпадении формата — пустой результат и fallback на
|
||||
ссылку (никогда не исключение).
|
||||
- **Минус:** усечение лимитом может обрезать длинные findings — приемлемо, полный
|
||||
контекст остаётся в файле, ссылка сохранена.
|
||||
- **Self-hosting риск:** правка ядра в общем прод-контейнере. Обязателен прогон
|
||||
`deploy-staging` (8501) перед прод-деплоем; прод-контейнер `orchestrator` (8500)
|
||||
не перезапускать в рамках разработки/тестинга. Граничный риск — исключение из
|
||||
парсера в `advance_stage`; закрыт контрактом «never raise» + юнит-кейсами на
|
||||
битый/пустой/отсутствующий ввод (AC-4, AC-5).
|
||||
|
||||
## Влияние на документацию (golden source)
|
||||
|
||||
В PR разработки (вместе с кодом) обновить:
|
||||
- `docs/architecture/README.md` — раздел **Stage Engine** / **Откаты**: упомянуть,
|
||||
что `task_desc` при заворотах reviewer/tester несёт дословные findings + ссылку,
|
||||
и новый модуль `src/review_parse.py` (defensive, never-raise).
|
||||
- `CHANGELOG.md` — запись ORCH-046.
|
||||
- `docs/architecture/internals.md` — по усмотрению reviewer, если детализируется
|
||||
поток отката.
|
||||
|
||||
## Связи
|
||||
|
||||
- BRD/ТЗ/AC: `docs/work-items/ORCH-046/01-brd.md`, `02-trz.md`, `03-acceptance-criteria.md`.
|
||||
- Образцы паттерна «never raise»: `src/frontmatter.py`,
|
||||
`src/qg/checks.py::_parse_tests_verdict`.
|
||||
- Каноны артефактов: `.openclaw/agents/reviewer.md` (`12-review.md` `## Findings`),
|
||||
`.openclaw/agents/tester.md` (`13-test-report.md` `result:` + тело).
|
||||
- Прецедент per-work-item ADR на правку парсинга: ORCH-047/ADR-001.
|
||||
- Технические риски: `docs/work-items/ORCH-046/10-tech-risks.md`.
|
||||
- Staging-страховка: `docs/architecture/adr/adr-0003-staging-gate.md`.
|
||||
29
docs/work-items/ORCH-046/10-tech-risks.md
Normal file
29
docs/work-items/ORCH-046/10-tech-risks.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Технические риски — ORCH-046
|
||||
|
||||
Work Item ID: ORCH-046
|
||||
Stage: architecture
|
||||
Author: architect
|
||||
Date: 2026-06-06
|
||||
|
||||
Связано: `06-adr/ADR-001-embed-findings-in-task-desc.md`.
|
||||
|
||||
| # | Риск | Вероятн. | Влияние | Митигация | Контроль (AC/тест) |
|
||||
|---|------|----------|---------|-----------|--------------------|
|
||||
| R-1 | Исключение из парсера всплывает в `advance_stage` → встаёт конвейер ВСЕХ проектов (self-hosting, общий инстанс) | Низк. | **Критич.** | Контракт «never raise» в `review_parse.py`; вызов в `stage_engine` обёрнут так, что пустой результат → fallback на прежнюю ссылку-строку | AC-4; юнит-кейсы «нет файла / битый YAML / пустой / только P3» в `tests/test_review_parse.py` |
|
||||
| R-2 | Регрессия в последовательности отката или полях `AdvanceResult` (меняется не только `task_desc`) | Низк. | Высок. | Жёсткий инвариант ТЗ §3.3: трогать ТОЛЬКО строку `task_desc`; порядок вызовов и условия retry неизменны | AC-6; существующие `tests/test_stage_engine.py` (rollback/retry) зелёные |
|
||||
| R-3 | Парсер чувствителен к формату артефактов: дрейф `12-review.md`/`13-test-report.md` → пустой результат | Сред. | Низк. | Распознавание P0/P1 устойчиво к регистру/тире; при несовпадении → `""` + fallback на ссылку (деградация, не отказ) | AC-1/AC-2/AC-4 |
|
||||
| R-4 | Раздувание `task_desc` длинными findings → рост стоимости/потеря фокуса developer-агента | Сред. | Низк. | Лимиты `MAX_FINDINGS_CHARS`/`MAX_FAILURES_CHARS` (~2000) + маркер `…(truncated)`; only P0/P1 (P2/P3 отброшены) | AC-1; проверка усечения в юнит-тестах |
|
||||
| R-5 | Случайный выход за out-of-scope (правка `check_*`, `QG_CHECKS`, сигнатур, webhooks, `build_status_comment`) | Низк. | Сред. | Явный out-of-scope в ТЗ §4/§6; ревью diff | AC-7; `test_qg_registry_snapshot` зелёный |
|
||||
| R-6 | Прод-деплой self без страховки staging | Низк. | **Критич.** | Обязательная стадия `deploy-staging` (8501); прод `orchestrator` (8500) не рестартить в разработке/тестинге | adr-0003; стадийный гейт `check_staging_status` |
|
||||
| R-7 | Дублирование defensive-парсинга (3-й модуль рядом с `frontmatter.py` и `_parse_tests_verdict`) → техдолг | Сред. | Низк. | Осознанно принято (как ORCH-016/ADR-001 §5): малый blast radius важнее DRY; слияние парсеров — отдельная follow-up задача | — (техдолг, не блокер) |
|
||||
|
||||
## Заметки
|
||||
|
||||
- **Граничный try в ядре.** Даже при контракте «never raise» в `review_parse`,
|
||||
вызов в `stage_engine` следует считать недоверенным: подстановка результата в
|
||||
`task_desc` не должна зависеть от внутренней корректности парсера. Fallback на
|
||||
ссылку-строку обязателен и при пустом результате, и при любой неожиданности.
|
||||
- **Эскалация не требуется.** Изменение укладывается в принципы (минимум
|
||||
зависимостей, raw-парсинг без новых либ, без новых компонентов/стадий/QG).
|
||||
Лейбл `arch:major-change` НЕ ставится; возврат в Анализ не требуется — ТЗ
|
||||
реализуемо без нарушения принципов.
|
||||
83
docs/work-items/ORCH-046/12-review.md
Normal file
83
docs/work-items/ORCH-046/12-review.md
Normal file
@@ -0,0 +1,83 @@
|
||||
---
|
||||
type: review
|
||||
work_item_id: ORCH-046
|
||||
verdict: APPROVED
|
||||
version: 1
|
||||
---
|
||||
|
||||
# Review ORCH-046
|
||||
|
||||
## Summary
|
||||
|
||||
Правка ядра «вариант A»: при заворотах на `development` `task_desc` теперь несёт
|
||||
**дословный must-fix текст** (P0/P1 ревьюера, причина FAIL тестера) вместо одной
|
||||
ссылки на файл. Извлечение вынесено в новый defensive-модуль `src/review_parse.py`
|
||||
с контрактом «never raise»; две rollback-ветки `_handle_qg_failure_rollbacks`
|
||||
встраивают текст и сохраняют ссылку как «Полный контекст», при пустом/битом
|
||||
артефакте — graceful-фоллбэк на прежнюю строку.
|
||||
|
||||
Реализация полностью соответствует ТЗ (`02-trz.md`), ADR-001 и всем критериям
|
||||
приёмки. Документация обновлена в этом же PR. Тесты зелёные (`461 passed`).
|
||||
|
||||
Проверено по осям:
|
||||
|
||||
**1. Соответствие ТЗ.** Сигнатуры `extract_review_findings`/`extract_test_failures`
|
||||
точно как в ТЗ §2; never-raise, логирование на `logger.debug`, модульные лимиты
|
||||
`MAX_FINDINGS_CHARS`/`MAX_FAILURES_CHARS`, отбрасывание frontmatter, устойчивость
|
||||
P0/P1-заголовков к регистру/тире, пропуск плейсхолдеров `(если есть)`/`<…>`,
|
||||
приоритет источников тестера (`## Вывод pytest` → FAIL-строки `## Результаты` →
|
||||
`## Итог`). Префикс `task_desc`, `reason` в tester-ветке, ссылка-фоллбэк — как
|
||||
предписано §3. API/БД/QG не тронуты (§4–6).
|
||||
|
||||
**2. Соответствие ADR-001.** Отдельный модуль (blast radius), путь через
|
||||
`get_worktree_path`, изоляция ядра (меняется только строка `task_desc`),
|
||||
последовательность отката и поля `AdvanceResult` сохранены. Per-work-item ADR
|
||||
обоснован. Реализация ⇄ решение совпадают.
|
||||
|
||||
**3. Качество кода.** Docstrings на всех публичных функциях; defensive `_read`
|
||||
ловит `OSError`, внешний `try/except Exception` в обоих экстракторах гарантирует
|
||||
never-raise (подтверждено кейсом на directory-path). Регэксп `_P01_HEADER_RE`
|
||||
корректно отсекает ложные совпадения (`P05` и т.п.). Код читабелен, без дублей.
|
||||
|
||||
**4. Качество тестов.** `tests/test_review_parse.py` покрывает TC-01..08 (findings
|
||||
есть / только P2-P3 / нет файла / битый YAML / усечение / регистр-тире / directory).
|
||||
`tests/test_stage_engine.py::TestRollbackTaskDescEmbedding` проверяет встраивание
|
||||
в обе ветки, graceful-фоллбэк, неизменность retry/rollback на 4-м заходе (alert
|
||||
вместо enqueue). Содержательные, не тривиальные.
|
||||
|
||||
## Findings
|
||||
|
||||
### P0 — Blocker
|
||||
- [ ] (нет)
|
||||
|
||||
### P1 — Must fix
|
||||
- [ ] (нет)
|
||||
|
||||
### P2 — Should fix
|
||||
- [ ] (нет)
|
||||
|
||||
## Соответствие критериям приёмки
|
||||
|
||||
- AC-1 (дословные P0/P1 в `task_desc`) — PASS: `Findings (P0/P1):\n{findings}`.
|
||||
- AC-2 (причина тестера: `reason` + фрагмент тела) — PASS: `Причина: {reason}` + `Детали:`.
|
||||
- AC-3 (ссылка на полный файл сохранена) — PASS: «Полный контекст»/fallback-ссылка в обеих ветках.
|
||||
- AC-4 (graceful never-raise) — PASS: `""`→ссылка-фоллбэк, исключений нет (тесты TC-03/04/07/08, directory-path).
|
||||
- AC-5 (тесты зелёные + новые юнит-тесты) — PASS: `461 passed`; все перечисленные кейсы присутствуют.
|
||||
- AC-6 (retry/rollback не изменены) — PASS: TC-12 + существующие rollback-тесты зелёные.
|
||||
- AC-7 (out-of-scope не затронут) — PASS: diff не касается `src/qg/checks.py`, `src/webhooks/*`, `usage.py`, `stages.py`, `main.py`; сигнатуры публичных функций не менялись.
|
||||
- AC-8 (документация + ADR) — PASS: ADR-001 заведён, `CHANGELOG.md` и `docs/architecture/README.md` обновлены.
|
||||
|
||||
## Документация
|
||||
|
||||
Обновлена корректно и в том же PR (golden source соблюдён):
|
||||
- `docs/work-items/ORCH-046/06-adr/ADR-001-embed-findings-in-task-desc.md` — заведён (правка ядра).
|
||||
- `CHANGELOG.md` — запись ORCH-046 в `[Unreleased] / Added`.
|
||||
- `docs/architecture/README.md` — добавлен компонент **Review/Test Parsers** и раздел **Обогащение `task_desc` при заворотах (ORCH-046)**.
|
||||
|
||||
Изменение `src/` сопровождено обновлением документации — требование п.4/п.6 правил
|
||||
агентов выполнено.
|
||||
|
||||
## Примечание (self-hosting)
|
||||
Правка ядра в общем прод-инстансе. Перед прод-деплоем обязательна стадия
|
||||
`deploy-staging` (8501) согласно ADR-001 / CLAUDE.md — это страховка следующих
|
||||
стадий, не блокер ревью.
|
||||
92
docs/work-items/ORCH-046/13-test-report.md
Normal file
92
docs/work-items/ORCH-046/13-test-report.md
Normal file
@@ -0,0 +1,92 @@
|
||||
---
|
||||
type: test-report
|
||||
work_item_id: ORCH-046
|
||||
result: PASS
|
||||
---
|
||||
|
||||
# Test Report — ORCH-046
|
||||
|
||||
Встраивание дословного must-fix текста findings reviewer/tester в `task_desc`
|
||||
при заворотах на `development` (новый модуль `src/review_parse.py` + две
|
||||
rollback-ветки `src/stage_engine.py`).
|
||||
|
||||
## Окружение
|
||||
- Python: 3.12.13
|
||||
- pytest: 8.3.3 (asyncio mode=AUTO)
|
||||
- Ветка: feature/ORCH-046-stage-engine-pass-reviewer-tes
|
||||
- Дата: 2026-06-06
|
||||
|
||||
## Результаты
|
||||
|
||||
| TC ID | Описание | Покрывает | Результат |
|
||||
|-------|----------|-----------|-----------|
|
||||
| TC-01 | `extract_review_findings` возвращает дословный P0/P1 текст | AC-1, AC-5 | PASS |
|
||||
| TC-02 | `extract_review_findings` → `""` при только P2/P3 | AC-5 | PASS |
|
||||
| TC-03 | `extract_review_findings` → `""` для отсутствующего файла | AC-4 | PASS |
|
||||
| TC-04 | `extract_review_findings` → `""` для битого/без секции файла | AC-4, AC-5 | PASS |
|
||||
| TC-05 | `extract_review_findings` усекает длинный текст с маркером truncated | AC-1 | PASS |
|
||||
| TC-06 | `extract_test_failures` извлекает фрагмент тела (Вывод pytest/FAIL/Итог) | AC-2, AC-5 | PASS |
|
||||
| TC-07 | `extract_test_failures` → `""` для отсутствующего файла | AC-4 | PASS |
|
||||
| TC-08 | `extract_test_failures` → `""` для битого/пустого отчёта | AC-4, AC-5 | PASS |
|
||||
| TC-09 | reviewer REQUEST_CHANGES → `task_desc` содержит P0/P1 + ссылку | AC-1, AC-3 | PASS |
|
||||
| TC-10 | tester FAIL → `task_desc` содержит reason + фрагмент + ссылку | AC-2, AC-3 | PASS |
|
||||
| TC-11 | graceful fallback при отсутствующем/битом файле (обе ветки) | AC-4, AC-3 | PASS |
|
||||
| TC-12 | rollback/retry поведение неизменно (alert на 4-й заход, поля AdvanceResult) | AC-6 | PASS |
|
||||
| TC-13 | Реестр `QG_CHECKS` не изменён (snapshot), гейты нетронуты | AC-7 | PASS |
|
||||
| TC-14 | Полный регресс существующего набора зелёный | AC-5, AC-6, AC-7 | PASS |
|
||||
|
||||
Сопоставление TC ↔ тесты:
|
||||
- TC-01..08 → `tests/test_review_parse.py` (`TestExtractReviewFindings`, `TestExtractTestFailures`), 14 кейсов, все PASS.
|
||||
- TC-09..12 → `tests/test_stage_engine.py::TestRollbackTaskDescEmbedding`, все PASS.
|
||||
- TC-13 → `tests/test_qg_registry_snapshot.py` (registry/callables/transitions snapshot), все PASS.
|
||||
- TC-14 → полный прогон `pytest tests/` → **461 passed**.
|
||||
|
||||
## Smoke test API (read-only, прод-инстанс не затронут)
|
||||
|
||||
| Endpoint | HTTP | Ответ |
|
||||
|----------|------|-------|
|
||||
| GET /health | 200 | `{"status":"ok","service":"orchestrator"}` |
|
||||
| GET /status | 200 | active_tasks включает task 37 (ORCH-046, stage=testing) |
|
||||
| GET /queue | 200 | counts: queued=0, running=1, failed=0; breaker=closed; preflight_ok=true |
|
||||
|
||||
> `curl` в окружении отсутствует — smoke выполнен через `urllib`. Только GET-запросы,
|
||||
> деструктивных операций над прод-контейнером не выполнялось (self-hosting safety).
|
||||
|
||||
## Вывод pytest
|
||||
|
||||
```
|
||||
============================= test session starts ==============================
|
||||
platform linux -- Python 3.12.13, pytest-8.3.3, pluggy-1.6.0
|
||||
rootdir: .../feature_ORCH-046-stage-engine-pass-reviewer-tes
|
||||
configfile: pytest.ini
|
||||
plugins: anyio-4.13.0, asyncio-0.23.8
|
||||
asyncio: mode=Mode.AUTO
|
||||
...
|
||||
======================== 461 passed, 1 warning in 7.59s ========================
|
||||
```
|
||||
|
||||
Прицельный прогон ORCH-046 (`test_review_parse.py` + `test_stage_engine.py` +
|
||||
`test_qg_registry_snapshot.py`): **53 passed**.
|
||||
|
||||
Единственный warning — преэкзистентный `PydanticDeprecatedSince20` в `src/config.py`
|
||||
(не связан с ORCH-046).
|
||||
|
||||
## Покрытие критериев приёмки
|
||||
|
||||
| AC | Критерий | Подтверждение | Статус |
|
||||
|----|----------|---------------|--------|
|
||||
| AC-1 | Дословные P0/P1 в `task_desc` | TC-01, TC-09 | PASS |
|
||||
| AC-2 | Причина тестера (reason + фрагмент) в `task_desc` | TC-06, TC-10 | PASS |
|
||||
| AC-3 | Ссылка на полный файл сохранена | TC-09, TC-10, TC-11 | PASS |
|
||||
| AC-4 | Парсер graceful (never-raise) | TC-03, TC-04, TC-07, TC-08, TC-11 | PASS |
|
||||
| AC-5 | Тесты зелёные + новые юнит-тесты | TC-14 (461 passed) | PASS |
|
||||
| AC-6 | Retry/rollback не изменены | TC-12 | PASS |
|
||||
| AC-7 | Out-of-scope не затронут | TC-13 | PASS |
|
||||
| AC-8 | Документация + ADR | проверено reviewer (12-review.md, APPROVED) | PASS |
|
||||
|
||||
## Итог
|
||||
|
||||
**PASS** — все 14 TC из тест-плана зелёные, полный регресс 461 passed,
|
||||
smoke API 200 по всем эндпоинтам, прод-инстанс здоров. Все критерии приёмки
|
||||
выполнены. Задача готова к стадии `deploy-staging` (8501) — обязательной
|
||||
страховке self-hosting перед прод-деплоем.
|
||||
90
docs/work-items/ORCH-046/15-staging-log.md
Normal file
90
docs/work-items/ORCH-046/15-staging-log.md
Normal file
@@ -0,0 +1,90 @@
|
||||
---
|
||||
staging_status: FAILED
|
||||
timestamp: 2026-06-06T04:47:45Z
|
||||
base_url: http://localhost:8501
|
||||
mode: stub
|
||||
result: 9/10 checks PASS
|
||||
failed_checks: [B6]
|
||||
---
|
||||
|
||||
# Staging Gate Log — ORCH-046
|
||||
|
||||
Staging test suite **FAILED**. Exit code 1 (9/10 checks passed).
|
||||
|
||||
## Verdict
|
||||
|
||||
The staging gate is **red**: one check failed (`B6`). Per pipeline policy a
|
||||
non-zero staging suite is `staging_status: FAILED` → rollback to `development`.
|
||||
|
||||
## Failed check
|
||||
|
||||
```
|
||||
✗ FAIL B6 Registry: sandbox present, prod ET/ORCH absent
|
||||
[sandbox=NO, prod-ET=YES(BAD!), prod-ORCH=YES(BAD!)]
|
||||
```
|
||||
|
||||
**What it means.** The staging container's project registry is **not isolated**:
|
||||
it sees the production projects `enduro-trails` (ET) and `orchestrator` (ORCH),
|
||||
and the `orchestrator-sandbox` (SANDBOX) project is **absent**. This violates the
|
||||
hard isolation invariant for staging (`docs/operations/INFRA.md`: «Staging видит
|
||||
ТОЛЬКО `orchestrator-sandbox` (SANDBOX) — изоляция»). The staging gate exists
|
||||
precisely to catch this class of safety breach before any prod deploy of the
|
||||
self-hosting orchestrator.
|
||||
|
||||
**Triage note (for humans).** This is a **staging environment / configuration**
|
||||
issue — the staging container's `ORCH_PROJECTS_JSON` is pointing at the prod
|
||||
registry instead of the sandbox-only registry. It is **not** a code regression
|
||||
introduced by the ORCH-046 changeset (which only touches `src/review_parse.py`
|
||||
and rollback `task_desc` enrichment). However, the gate is authoritative and red,
|
||||
so the work item cannot pass to `deploy`. Fix the staging `.env.staging` /
|
||||
`ORCH_PROJECTS_JSON` to expose only SANDBOX, re-run the staging suite, and the
|
||||
gate will go green.
|
||||
|
||||
> ⚠️ Safety note: the first run aborted at `A3` because `ORCH_STAGING` was not
|
||||
> set in the runner env (the script's guard against accidentally hitting prod).
|
||||
> Re-run with `ORCH_STAGING=true` against the staging URL (8501) executed the
|
||||
> full suite. Prod (8500) was never touched.
|
||||
|
||||
## Full suite output
|
||||
|
||||
```
|
||||
============================================================
|
||||
ORCH-33 Staging Check Suite
|
||||
base_url : http://localhost:8501
|
||||
mode : stub
|
||||
utc_time : 2026-06-06T04:47:27.628664+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=2fcbcb0c-1b29-4b76-8ba8-a8fe42cebdb4]
|
||||
· 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-011-staging-check-e2e-20260606t044]
|
||||
· 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=7, status=queued, agent=analyst]
|
||||
|
||||
[CLEANUP]
|
||||
✓ PASS CLEANUP: deleted branch 'feature/SANDBOX-011-staging-check-e2e-20260606t044' (HTTP 204)
|
||||
✓ PASS CLEANUP: deleted Plane issue 2fcbcb0c-1b29-4b76-8ba8-a8fe42cebdb4 (HTTP 204)
|
||||
· CLEANUP DB: no task row found for plane_id=2fcbcb0c-1b29-4b76-8ba8-a8fe42cebdb4
|
||||
· CLEANUP DB dedup: no such table: events_dedup
|
||||
|
||||
============================================================
|
||||
RESULT: 9/10 checks PASS
|
||||
============================================================
|
||||
EXIT_CODE=1
|
||||
```
|
||||
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
|
||||
```
|
||||
42
docs/work-items/ORCH-048/14-deploy-log.md
Normal file
42
docs/work-items/ORCH-048/14-deploy-log.md
Normal file
@@ -0,0 +1,42 @@
|
||||
---
|
||||
deploy_status: SUCCESS
|
||||
timestamp: 2026-06-06T07:12:00Z
|
||||
work_item: ORCH-048
|
||||
target: orchestrator (prod, self-hosting)
|
||||
restart_required: false
|
||||
rebuild_required: false
|
||||
staging_gate: SUCCESS
|
||||
---
|
||||
|
||||
# Production Deploy Log — ORCH-048
|
||||
|
||||
Production rollout of ORCH-048 (`fix(staging_check): B6 reads registry from running staging instance env`) is **complete on merge** — no prod container rebuild or restart was required, and none was performed.
|
||||
|
||||
## Precondition: staging gate
|
||||
|
||||
`deploy` is reachable only because the staging gate (`deploy-staging`) passed:
|
||||
|
||||
- `15-staging-log.md` → `staging_status: SUCCESS`, **10/10 checks PASS** (incl. B6 registry-isolation, the fix's target), run inside `orchestrator-staging` (8501).
|
||||
|
||||
## Change scope (why no restart)
|
||||
|
||||
The work item changes touch **only bind-mounted / non-runtime artifacts** — `src/` (the code running in the prod container) and the `Dockerfile` are untouched:
|
||||
|
||||
| File | Kind | How it reaches prod |
|
||||
|------|------|---------------------|
|
||||
| `scripts/staging_check.py` | bind-mounted (`/home/slin/repos` → `/repos`); **not** copied into the image (`scripts/` absent from `/app`) | host `git pull` → live immediately |
|
||||
| `.openclaw/agents/deployer.md` | bind-mounted agent prompt, read at agent launch | host `git pull` → live on next agent run |
|
||||
| `CHANGELOG.md`, `docs/operations/STAGING_CHECK.md` | docs | n/a |
|
||||
| `tests/test_staging_check_b6.py` | test, not deployed | n/a |
|
||||
|
||||
Because nothing in `src/` or the image changed, there is **no container rebuild and no restart** for the shared prod `orchestrator` (8500). Per CLAUDE.md / INFRA.md self-hosting rules, the prod container that serves all projects (enduro-trails + orchestrator) was **not** touched — zero group-risk.
|
||||
|
||||
## Deploy action
|
||||
|
||||
- **Prod container restart/rebuild:** not required, not performed (guardrail: never restart prod `orchestrator` within an ORCH task).
|
||||
- **Real docker/SSH deploy hook** (`scripts/orchestrator-deploy-hook.sh`): not triggered by this agent (not explicitly instructed; reserved for Owner per ORCH-36).
|
||||
- **Effective rollout:** merge of this branch to `main` + routine host `git pull` makes the corrected `staging_check.py` and `deployer.md` live; the prod app process is unaffected.
|
||||
|
||||
## Verdict
|
||||
|
||||
`deploy_status: SUCCESS` — staging gate green, change is bind-mount-only, prod instance untouched, no rollback needed.
|
||||
50
docs/work-items/ORCH-048/15-staging-log.md
Normal file
50
docs/work-items/ORCH-048/15-staging-log.md
Normal file
@@ -0,0 +1,50 @@
|
||||
---
|
||||
staging_status: SUCCESS
|
||||
timestamp: 2026-06-06T07:08:59Z
|
||||
base_url: http://localhost:8501
|
||||
work_item: ORCH-048
|
||||
mode: stub
|
||||
result: 10/10 checks PASS
|
||||
---
|
||||
|
||||
# Staging Gate Log — ORCH-048
|
||||
|
||||
Staging test suite completed against the live `orchestrator-staging` instance (port 8501). **All 10/10 checks passed.**
|
||||
|
||||
## Execution context
|
||||
|
||||
- **Where**: inside the `orchestrator-staging` container via Docker Engine API exec (canonical per ORCH-048 / ADR-001; `docker` CLI not present in this agent env, so the bind-mounted socket `/var/run/docker.sock` was used directly).
|
||||
- **Command**: `python3 /repos/orchestrator/scripts/staging_check.py --base-url http://localhost:8501 --mode stub`
|
||||
- **Exit code**: `0`
|
||||
- **Container state**: `orchestrator-staging` running (Up 25 hours).
|
||||
|
||||
Running inside the container is required so the B6 registry-isolation check reads the registry from the running instance's own process-env (`.env.staging` → `ORCH_PROJECTS_JSON` = sandbox-only). This is precisely the behaviour ORCH-048 corrects.
|
||||
|
||||
## Results
|
||||
|
||||
```
|
||||
[Block A] SMOKE
|
||||
✓ PASS A1 GET /health → 200 status=ok
|
||||
✓ PASS A2 GET /queue → 200 with counts/max_concurrency/resilience
|
||||
✓ PASS A3 ORCH_STAGING=true (not prod)
|
||||
|
||||
[Block B] ACCESS
|
||||
✓ PASS B4 Plane: sandbox project accessible (found 5 project(s), sandbox=YES)
|
||||
✓ PASS B5 Gitea: orchestrator-sandbox accessible, push=true
|
||||
✓ PASS B6 Registry: sandbox present, prod ET/ORCH absent [sandbox=YES, prod-ET=NO(good), prod-ORCH=NO(good)]
|
||||
|
||||
[Block C] E2E (mode=stub)
|
||||
✓ PASS C7 Create issue in Plane SANDBOX
|
||||
✓ PASS C8 Trigger pipeline via /webhook/plane
|
||||
✓ PASS C9a Branch appears in orchestrator-sandbox
|
||||
✓ PASS C9b Analyst job enqueued in staging queue
|
||||
|
||||
[CLEANUP]
|
||||
✓ PASS CLEANUP: deleted sandbox branch, Plane issue, and DB rows
|
||||
|
||||
============================================================
|
||||
RESULT: 10/10 checks PASS
|
||||
============================================================
|
||||
```
|
||||
|
||||
**B6 verdict (the ORCH-048 target check): PASS** — registry read from the running staging instance correctly shows sandbox present and prod ET/ORCH absent, with no false FAIL / spurious rollback.
|
||||
@@ -121,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 = ""
|
||||
|
||||
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]:
|
||||
|
||||
205
src/review_parse.py
Normal file
205
src/review_parse.py
Normal file
@@ -0,0 +1,205 @@
|
||||
"""Defensive extractors for reviewer / tester artifact bodies (ORCH-046).
|
||||
|
||||
When a task is rolled back to ``development`` the stage engine builds the
|
||||
``task_desc`` that ends up in the developer agent's ``.task-dev.md``. Historically
|
||||
that text only carried a *link* to the artifact file (12-review.md /
|
||||
13-test-report.md); the developer agent had to go read the file, and the key
|
||||
must-fix points (reviewer P0/P1 findings, tester failure reason) were lost in
|
||||
transit — "испорченный телефон" that burns the retry budget.
|
||||
|
||||
This module extracts the **verbatim** must-fix text so the stage engine can embed
|
||||
it directly in ``task_desc`` (ADR docs/work-items/ORCH-046/06-adr/ADR-001-*).
|
||||
|
||||
Contract — **never raises** (mirrors ``src/frontmatter.py`` and
|
||||
``src/qg/checks.py::_parse_tests_verdict``): any error — missing file, IOError,
|
||||
malformed markdown/YAML, missing section — yields ``""``. The caller then falls
|
||||
back to the previous link-only ``task_desc``. No network calls; disk reads only.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
logger = logging.getLogger("orchestrator.review_parse")
|
||||
|
||||
# Truncation limits (module-level per ТЗ §2.3). The full context always stays in
|
||||
# the artifact file; the embedded text is a focused excerpt.
|
||||
MAX_FINDINGS_CHARS = 2000
|
||||
MAX_FAILURES_CHARS = 2000
|
||||
|
||||
_TRUNCATED_MARKER = "\n…(truncated)"
|
||||
|
||||
# Recognize a `### P0`/`### P1` subsection header by the presence of the P0/P1
|
||||
# token, tolerant to case and the dash/em-dash that follows it.
|
||||
_P01_HEADER_RE = re.compile(r"(?<![A-Za-z0-9])p[01](?![0-9])", re.IGNORECASE)
|
||||
|
||||
|
||||
def _read(path: str) -> str | None:
|
||||
"""Read a file as UTF-8. Never raises; returns None on any OS error."""
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
||||
return f.read()
|
||||
except OSError as e:
|
||||
logger.debug(f"review_parse: cannot open {path}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _strip_frontmatter(content: str) -> str:
|
||||
"""Drop a leading ``--- … ---`` YAML frontmatter block, if present."""
|
||||
if content.startswith("---"):
|
||||
parts = content.split("---", 2)
|
||||
if len(parts) >= 3:
|
||||
return parts[2]
|
||||
return content
|
||||
|
||||
|
||||
def _truncate(text: str, limit: int) -> str:
|
||||
"""Trim ``text`` to ``limit`` chars, appending a truncation marker if cut."""
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[:limit].rstrip() + _TRUNCATED_MARKER
|
||||
|
||||
|
||||
def _section_body(md: str, heading_token: str) -> str:
|
||||
"""Return the body lines under the first ``## <…heading_token…>`` heading.
|
||||
|
||||
Capture stops at the next level-2 (``## ``) heading. Matching is
|
||||
case-insensitive substring match on the heading line, so callers pass a token
|
||||
like ``"Вывод pytest"`` or ``"Findings"``. ``### ``-level headers do NOT
|
||||
delimit the section (they start with ``"### "``, not ``"## "``).
|
||||
"""
|
||||
out: list[str] = []
|
||||
capturing = False
|
||||
for line in md.splitlines():
|
||||
if line.startswith("## "):
|
||||
if capturing:
|
||||
break
|
||||
if heading_token.lower() in line.lower():
|
||||
capturing = True
|
||||
continue
|
||||
if capturing:
|
||||
out.append(line)
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def _is_placeholder_item(text: str) -> bool:
|
||||
"""True for empty or template-placeholder list items (non-substantive).
|
||||
|
||||
The canonical reviewer template seeds each severity with
|
||||
``- [ ] <описание> (если есть)``. Such lines must be ignored so an empty P0/P1
|
||||
subsection does not leak the placeholder into ``task_desc``.
|
||||
"""
|
||||
t = text.strip()
|
||||
if not t:
|
||||
return True
|
||||
if "(если есть)" in t:
|
||||
return True
|
||||
# An item whose entire payload is an angle-bracket placeholder, e.g. "<описание>".
|
||||
if t.startswith("<") and t.endswith(">"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _item_payload(line: str) -> str | None:
|
||||
"""If ``line`` is a markdown list item, return its payload text; else None.
|
||||
|
||||
Handles ``- foo``, ``* foo`` and checkbox forms ``- [ ] foo`` / ``- [x] foo``.
|
||||
"""
|
||||
m = re.match(r"\s*[-*]\s+(?:\[[ xX]?\]\s*)?(.*)$", line)
|
||||
if not m:
|
||||
return None
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def _findings_subsections(findings_body: str):
|
||||
"""Yield ``(header_line, body_lines)`` for each ``### `` subsection."""
|
||||
header: str | None = None
|
||||
body: list[str] = []
|
||||
for line in findings_body.splitlines():
|
||||
if line.startswith("### "):
|
||||
if header is not None:
|
||||
yield header, body
|
||||
header = line
|
||||
body = []
|
||||
elif header is not None:
|
||||
body.append(line)
|
||||
if header is not None:
|
||||
yield header, body
|
||||
|
||||
|
||||
def extract_review_findings(path: str) -> str:
|
||||
"""Дословный текст P0/P1 findings из 12-review.md. Never raises; '' при ошибке/пусто.
|
||||
|
||||
Reads the ``## Findings`` section of a reviewer report and returns the verbatim
|
||||
P0 (Blocker) and P1 (Must fix) subsection items, suitable for embedding in a
|
||||
rollback ``task_desc``. P2/P3 are ignored. Empty/placeholder-only subsections
|
||||
are skipped; if no substantive P0/P1 item exists, returns ``""``. The result is
|
||||
truncated to ``MAX_FINDINGS_CHARS``.
|
||||
"""
|
||||
content = _read(path)
|
||||
if content is None:
|
||||
return ""
|
||||
|
||||
try:
|
||||
body = _strip_frontmatter(content)
|
||||
findings_body = _section_body(body, "Findings")
|
||||
if not findings_body.strip():
|
||||
return ""
|
||||
|
||||
blocks: list[str] = []
|
||||
for header, sub_body in _findings_subsections(findings_body):
|
||||
if not _P01_HEADER_RE.search(header):
|
||||
continue
|
||||
kept: list[str] = []
|
||||
for line in sub_body:
|
||||
payload = _item_payload(line)
|
||||
if payload is None:
|
||||
continue
|
||||
if _is_placeholder_item(payload):
|
||||
continue
|
||||
kept.append(line.rstrip())
|
||||
if kept:
|
||||
blocks.append("\n".join([header.rstrip(), *kept]))
|
||||
|
||||
if not blocks:
|
||||
return ""
|
||||
return _truncate("\n\n".join(blocks), MAX_FINDINGS_CHARS)
|
||||
except Exception as e: # defensive: never raise out of the extractor
|
||||
logger.debug(f"review_parse: extract_review_findings failed for {path}: {e}")
|
||||
return ""
|
||||
|
||||
|
||||
def extract_test_failures(path: str) -> str:
|
||||
"""Релевантный фрагмент тела 13-test-report.md (причина FAIL). Never raises; '' при ошибке/пусто.
|
||||
|
||||
Picks the first non-empty source, in priority order:
|
||||
1. ``## Вывод pytest`` — the pytest run output (shows failing tests);
|
||||
2. rows of the ``## Результаты`` table that contain ``FAIL``;
|
||||
3. ``## Итог`` — the verdict summary.
|
||||
The result is truncated to ``MAX_FAILURES_CHARS``. The gate ``reason`` is added
|
||||
by the caller; this returns the report-body excerpt on top of it.
|
||||
"""
|
||||
content = _read(path)
|
||||
if content is None:
|
||||
return ""
|
||||
|
||||
try:
|
||||
# 1. pytest output.
|
||||
pytest_out = _section_body(content, "Вывод pytest").strip()
|
||||
if pytest_out:
|
||||
return _truncate(pytest_out, MAX_FAILURES_CHARS)
|
||||
|
||||
# 2. FAIL rows from the results table.
|
||||
results = _section_body(content, "Результаты")
|
||||
fail_rows = [ln.rstrip() for ln in results.splitlines() if "FAIL" in ln.upper()]
|
||||
if fail_rows:
|
||||
return _truncate("\n".join(fail_rows).strip(), MAX_FAILURES_CHARS)
|
||||
|
||||
# 3. Verdict summary.
|
||||
itog = _section_body(content, "Итог").strip()
|
||||
if itog:
|
||||
return _truncate(itog, MAX_FAILURES_CHARS)
|
||||
|
||||
return ""
|
||||
except Exception as e: # defensive: never raise out of the extractor
|
||||
logger.debug(f"review_parse: extract_test_failures failed for {path}: {e}")
|
||||
return ""
|
||||
@@ -32,6 +32,7 @@ from dataclasses import dataclass, field
|
||||
from .db import get_db, update_task_stage, enqueue_job
|
||||
from .stages import get_next_stage, get_qg_for_stage, get_agent_for_stage
|
||||
from .git_worktree import get_worktree_path
|
||||
from .review_parse import extract_review_findings, extract_test_failures
|
||||
from .qg.checks import QG_CHECKS
|
||||
from .notifications import (
|
||||
notify_stage_change,
|
||||
@@ -416,12 +417,24 @@ def _handle_qg_failure_rollbacks(
|
||||
result.rolled_back_to = "development"
|
||||
retry_count = _developer_retry_count(task_id)
|
||||
if retry_count < MAX_DEVELOPER_RETRIES:
|
||||
task_desc = (
|
||||
# ORCH-046: embed the verbatim P0/P1 findings into task_desc so the
|
||||
# developer agent sees the must-fix points directly (not just a link).
|
||||
# extract_review_findings never raises; "" -> graceful link-only fallback.
|
||||
review_ref = f"docs/work-items/{work_item_id}/12-review.md"
|
||||
review_path = os.path.join(get_worktree_path(repo, branch), review_ref)
|
||||
findings = extract_review_findings(review_path)
|
||||
head = (
|
||||
f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n"
|
||||
f"Stage: development\nNote: REQUEST_CHANGES from reviewer "
|
||||
f"(attempt {retry_count+1}/3). Fix findings in "
|
||||
f"docs/work-items/{work_item_id}/12-review.md"
|
||||
f"(attempt {retry_count+1}/3)."
|
||||
)
|
||||
if findings:
|
||||
task_desc = (
|
||||
f"{head}\nFindings (P0/P1):\n{findings}\n"
|
||||
f"Полный контекст: {review_ref}"
|
||||
)
|
||||
else:
|
||||
task_desc = f"{head} Fix findings in {review_ref}"
|
||||
new_job = enqueue_job("developer", repo, task_desc, task_id=task_id)
|
||||
result.enqueued_agent = "developer"
|
||||
result.enqueued_job_id = new_job
|
||||
@@ -452,11 +465,23 @@ def _handle_qg_failure_rollbacks(
|
||||
)
|
||||
retry_count = _developer_retry_count(task_id)
|
||||
if retry_count < MAX_DEVELOPER_RETRIES:
|
||||
task_desc = (
|
||||
# ORCH-046: embed the gate `reason` plus a verbatim excerpt of the
|
||||
# test-report body (pytest output / FAIL rows / Итог) into task_desc.
|
||||
# extract_test_failures never raises; "" -> graceful reason+link fallback.
|
||||
report_ref = f"docs/work-items/{work_item_id}/13-test-report.md"
|
||||
report_path = os.path.join(get_worktree_path(repo, branch), report_ref)
|
||||
failures = extract_test_failures(report_path)
|
||||
head = (
|
||||
f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n"
|
||||
f"Stage: development\nNote: Tests FAILED. "
|
||||
f"Fix failures described in docs/work-items/{work_item_id}/13-test-report.md"
|
||||
f"Stage: development\nNote: Tests FAILED. Причина: {reason}."
|
||||
)
|
||||
if failures:
|
||||
task_desc = (
|
||||
f"{head}\nДетали:\n{failures}\n"
|
||||
f"Полный контекст: {report_ref}"
|
||||
)
|
||||
else:
|
||||
task_desc = f"{head} Fix failures described in {report_ref}"
|
||||
new_job = enqueue_job("developer", repo, task_desc, task_id=task_id)
|
||||
result.enqueued_agent = "developer"
|
||||
result.enqueued_job_id = new_job
|
||||
|
||||
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
|
||||
|
||||
237
tests/test_review_parse.py
Normal file
237
tests/test_review_parse.py
Normal file
@@ -0,0 +1,237 @@
|
||||
"""Unit tests for src/review_parse (ORCH-046).
|
||||
|
||||
Covers the defensive extractors that pull verbatim must-fix text out of the
|
||||
reviewer / tester artifacts for embedding into the rollback ``task_desc``:
|
||||
|
||||
- extract_review_findings (12-review.md, ## Findings -> P0/P1)
|
||||
- extract_test_failures (13-test-report.md, pytest/FAIL/Итог excerpt)
|
||||
|
||||
Both must NEVER raise (return "" on missing/broken/empty input) and must ignore
|
||||
template placeholders / non-must-fix severities. See 04-test-plan.yaml (TC-01..08).
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
from src.review_parse import (
|
||||
extract_review_findings,
|
||||
extract_test_failures,
|
||||
MAX_FINDINGS_CHARS,
|
||||
MAX_FAILURES_CHARS,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def write_file(tmp_path):
|
||||
def _w(name: str, content: str) -> str:
|
||||
p = tmp_path / name
|
||||
p.write_text(content, encoding="utf-8")
|
||||
return str(p)
|
||||
return _w
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# extract_review_findings
|
||||
# ---------------------------------------------------------------------------
|
||||
_REVIEW_WITH_FINDINGS = """---
|
||||
type: review
|
||||
work_item_id: ORCH-046
|
||||
verdict: REQUEST_CHANGES
|
||||
version: 1
|
||||
---
|
||||
|
||||
# Review ORCH-046
|
||||
|
||||
## Summary
|
||||
Несколько проблем.
|
||||
|
||||
## Findings
|
||||
|
||||
### P0 — Blocker
|
||||
- [ ] Документация не обновлена при изменении src/review_parse.py
|
||||
|
||||
### P1 — Must fix
|
||||
- [ ] extract_test_failures не обрабатывает пустой отчёт
|
||||
- [ ] Отсутствует docstring у _section_body
|
||||
|
||||
### P2 — Should fix
|
||||
- [ ] Переименовать переменную blocks в more descriptive
|
||||
|
||||
## Документация
|
||||
Требует обновления README.
|
||||
"""
|
||||
|
||||
|
||||
class TestExtractReviewFindings:
|
||||
def test_tc01_returns_verbatim_p0_p1(self, write_file):
|
||||
"""TC-01: P0/P1 findings present -> verbatim text returned (AC-1, AC-5)."""
|
||||
path = write_file("12-review.md", _REVIEW_WITH_FINDINGS)
|
||||
out = extract_review_findings(path)
|
||||
# P0 + P1 verbatim items present.
|
||||
assert "Документация не обновлена при изменении src/review_parse.py" in out
|
||||
assert "extract_test_failures не обрабатывает пустой отчёт" in out
|
||||
assert "Отсутствует docstring у _section_body" in out
|
||||
# Subsection headers preserved.
|
||||
assert "P0" in out and "P1" in out
|
||||
# P2 must NOT leak in.
|
||||
assert "Переименовать переменную" not in out
|
||||
|
||||
def test_tc02_only_p2_p3_returns_empty(self, write_file):
|
||||
"""TC-02: only P2/P3 (no must-fix P0/P1) -> '' (AC-5)."""
|
||||
content = """---
|
||||
verdict: REQUEST_CHANGES
|
||||
---
|
||||
|
||||
## Findings
|
||||
|
||||
### P0 — Blocker
|
||||
- [ ] <описание> (если есть)
|
||||
|
||||
### P1 — Must fix
|
||||
- [ ] <описание> (если есть)
|
||||
|
||||
### P2 — Should fix
|
||||
- [ ] Косметика в naming
|
||||
"""
|
||||
path = write_file("12-review.md", content)
|
||||
assert extract_review_findings(path) == ""
|
||||
|
||||
def test_tc03_missing_file_returns_empty(self):
|
||||
"""TC-03: non-existent path -> '' without raising (AC-4)."""
|
||||
missing = os.path.join(tempfile.gettempdir(), "no-such-review-orch046.md")
|
||||
assert extract_review_findings(missing) == ""
|
||||
|
||||
def test_tc04_broken_or_no_findings_section_returns_empty(self, write_file):
|
||||
"""TC-04: empty file / markdown without ## Findings -> '' (AC-4, AC-5)."""
|
||||
# Empty file.
|
||||
assert extract_review_findings(write_file("empty.md", "")) == ""
|
||||
# No Findings section.
|
||||
no_section = "# Review\n\n## Summary\nвсё хорошо\n"
|
||||
assert extract_review_findings(write_file("nofind.md", no_section)) == ""
|
||||
# Broken YAML frontmatter (unterminated) — body parsing still graceful.
|
||||
broken = "---\nverdict: [unclosed\n# Review\nno findings here\n"
|
||||
assert extract_review_findings(write_file("broken.md", broken)) == ""
|
||||
|
||||
def test_tc05_long_findings_truncated(self, write_file):
|
||||
"""TC-05: very long findings truncated to limit with marker (AC-1)."""
|
||||
big_item = "- [ ] " + ("x" * 5000)
|
||||
content = f"## Findings\n\n### P0 — Blocker\n{big_item}\n"
|
||||
path = write_file("12-review.md", content)
|
||||
out = extract_review_findings(path)
|
||||
assert len(out) <= MAX_FINDINGS_CHARS + len("\n…(truncated)")
|
||||
assert "…(truncated)" in out
|
||||
|
||||
def test_case_insensitive_and_dash_tolerant_header(self, write_file):
|
||||
"""P0/P1 recognized regardless of case / dash style."""
|
||||
content = """## Findings
|
||||
|
||||
### p0 - blocker
|
||||
- [ ] Нижний регистр заголовка
|
||||
|
||||
### P1 — Must fix
|
||||
- [ ] Em-dash заголовок
|
||||
"""
|
||||
out = extract_review_findings(write_file("12-review.md", content))
|
||||
assert "Нижний регистр заголовка" in out
|
||||
assert "Em-dash заголовок" in out
|
||||
|
||||
def test_never_raises_on_directory_path(self, tmp_path):
|
||||
"""Passing a directory path must not raise -> ''."""
|
||||
assert extract_review_findings(str(tmp_path)) == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# extract_test_failures
|
||||
# ---------------------------------------------------------------------------
|
||||
_REPORT_FAIL = """---
|
||||
type: test-report
|
||||
work_item_id: ORCH-046
|
||||
result: FAIL
|
||||
---
|
||||
|
||||
# Test Report — ORCH-046
|
||||
|
||||
## Окружение
|
||||
- Python: 3.12
|
||||
|
||||
## Результаты
|
||||
|
||||
| TC ID | Описание | Результат |
|
||||
|-------|----------|-----------|
|
||||
| TC-01 | парсер findings | PASS |
|
||||
| TC-09 | rollback task_desc | FAIL |
|
||||
|
||||
## Вывод pytest
|
||||
FAILED tests/test_stage_engine.py::TestReviewerRequestChanges::test_embed - AssertionError
|
||||
1 failed, 40 passed in 2.13s
|
||||
|
||||
## Итог
|
||||
FAIL
|
||||
"""
|
||||
|
||||
|
||||
class TestExtractTestFailures:
|
||||
def test_tc06_extracts_pytest_output(self, write_file):
|
||||
"""TC-06: relevant body excerpt (pytest output) from FAIL report (AC-2, AC-5)."""
|
||||
path = write_file("13-test-report.md", _REPORT_FAIL)
|
||||
out = extract_test_failures(path)
|
||||
assert "FAILED tests/test_stage_engine.py" in out
|
||||
assert "1 failed, 40 passed" in out
|
||||
|
||||
def test_priority_falls_back_to_fail_rows(self, write_file):
|
||||
"""No pytest section -> FAIL rows of the results table are used."""
|
||||
content = """---
|
||||
result: FAIL
|
||||
---
|
||||
|
||||
## Результаты
|
||||
|
||||
| TC ID | Описание | Результат |
|
||||
|-------|----------|-----------|
|
||||
| TC-01 | ok | PASS |
|
||||
| TC-09 | broken | FAIL |
|
||||
|
||||
## Итог
|
||||
FAIL
|
||||
"""
|
||||
out = extract_test_failures(write_file("13-test-report.md", content))
|
||||
assert "TC-09" in out
|
||||
assert "broken" in out
|
||||
# PASS rows are not failure-relevant.
|
||||
assert "TC-01" not in out
|
||||
|
||||
def test_priority_falls_back_to_itog(self, write_file):
|
||||
"""No pytest section and no FAIL rows -> Итог summary is used."""
|
||||
content = """---
|
||||
result: FAIL
|
||||
---
|
||||
|
||||
## Итог
|
||||
Регресс упал: смотрите CI лог.
|
||||
"""
|
||||
out = extract_test_failures(write_file("13-test-report.md", content))
|
||||
assert "Регресс упал" in out
|
||||
|
||||
def test_tc07_missing_file_returns_empty(self):
|
||||
"""TC-07: non-existent path -> '' without raising (AC-4)."""
|
||||
missing = os.path.join(tempfile.gettempdir(), "no-such-report-orch046.md")
|
||||
assert extract_test_failures(missing) == ""
|
||||
|
||||
def test_tc08_broken_or_empty_report_returns_empty(self, write_file):
|
||||
"""TC-08: empty / section-less report -> '' without raising (AC-4, AC-5)."""
|
||||
assert extract_test_failures(write_file("empty.md", "")) == ""
|
||||
no_sections = "---\nresult: FAIL\n---\n\n# Test Report\nничего полезного\n"
|
||||
assert extract_test_failures(write_file("nosec.md", no_sections)) == ""
|
||||
|
||||
def test_long_failures_truncated(self, write_file):
|
||||
"""Long pytest output is truncated to the limit with a marker."""
|
||||
big = "x" * 5000
|
||||
content = f"## Вывод pytest\n{big}\n"
|
||||
out = extract_test_failures(write_file("13-test-report.md", content))
|
||||
assert len(out) <= MAX_FAILURES_CHARS + len("\n…(truncated)")
|
||||
assert "…(truncated)" in out
|
||||
|
||||
def test_never_raises_on_directory_path(self, tmp_path):
|
||||
assert extract_test_failures(str(tmp_path)) == ""
|
||||
@@ -101,6 +101,14 @@ def _jobs():
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def _job_contents():
|
||||
"""task_content of every enqueued job, oldest first (ORCH-046 task_desc check)."""
|
||||
conn = get_db()
|
||||
rows = conn.execute("SELECT task_content FROM jobs ORDER BY id").fetchall()
|
||||
conn.close()
|
||||
return [r[0] for r in rows]
|
||||
|
||||
|
||||
def _add_developer_runs(task_id, n):
|
||||
conn = get_db()
|
||||
for _ in range(n):
|
||||
@@ -335,6 +343,179 @@ class TestTesterFail:
|
||||
assert _jobs() == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ORCH-046: rollback task_desc carries verbatim reviewer/tester must-fix text
|
||||
# ---------------------------------------------------------------------------
|
||||
_REVIEW_MD = """---
|
||||
type: review
|
||||
work_item_id: ET-001
|
||||
verdict: REQUEST_CHANGES
|
||||
version: 1
|
||||
---
|
||||
|
||||
# Review ET-001
|
||||
|
||||
## Summary
|
||||
Есть блокеры.
|
||||
|
||||
## Findings
|
||||
|
||||
### P0 — Blocker
|
||||
- [ ] Гонка в claim_next_job: отсутствует guard в WHERE
|
||||
|
||||
### P1 — Must fix
|
||||
- [ ] Нет обработки OSError при чтении отчёта
|
||||
|
||||
### P2 — Should fix
|
||||
- [ ] Переименовать blocks
|
||||
"""
|
||||
|
||||
_REPORT_MD = """---
|
||||
type: test-report
|
||||
work_item_id: ET-001
|
||||
result: FAIL
|
||||
---
|
||||
|
||||
# Test Report — ET-001
|
||||
|
||||
## Результаты
|
||||
|
||||
| TC ID | Описание | Результат |
|
||||
|-------|----------|-----------|
|
||||
| TC-09 | rollback | FAIL |
|
||||
|
||||
## Вывод pytest
|
||||
FAILED tests/test_stage_engine.py::TestTaskDescEmbedding - AssertionError
|
||||
1 failed, 50 passed in 3.01s
|
||||
|
||||
## Итог
|
||||
FAIL
|
||||
"""
|
||||
|
||||
|
||||
class TestRollbackTaskDescEmbedding:
|
||||
"""ORCH-046 AC-1/AC-2/AC-3/AC-4: the rollback task_desc embeds verbatim
|
||||
must-fix text (reviewer P0/P1, tester reason + report excerpt) plus the link.
|
||||
"""
|
||||
|
||||
def _patch_worktree(self, monkeypatch, tmp_path, work_item_id, filename, body):
|
||||
"""Make get_worktree_path resolve to tmp_path and seed the artifact file."""
|
||||
artifact = tmp_path / "docs" / "work-items" / work_item_id
|
||||
artifact.mkdir(parents=True, exist_ok=True)
|
||||
(artifact / filename).write_text(body, encoding="utf-8")
|
||||
monkeypatch.setattr(
|
||||
stage_engine, "get_worktree_path", lambda repo, branch: str(tmp_path)
|
||||
)
|
||||
|
||||
def test_tc09_reviewer_embeds_p0_p1_and_link(self, monkeypatch, tmp_path):
|
||||
"""TC-09: reviewer REQUEST_CHANGES -> task_desc has verbatim P0/P1 + link."""
|
||||
monkeypatch.setattr(
|
||||
stage_engine, "QG_CHECKS",
|
||||
{**stage_engine.QG_CHECKS,
|
||||
"check_reviewer_verdict": _fail("verdict: REQUEST_CHANGES")},
|
||||
)
|
||||
self._patch_worktree(monkeypatch, tmp_path, "ET-001", "12-review.md", _REVIEW_MD)
|
||||
task_id = _make_task("review")
|
||||
advance_stage(task_id, "review", "enduro-trails", "ET-001",
|
||||
"feature/ET-001-x", finished_agent="reviewer")
|
||||
contents = _job_contents()
|
||||
assert len(contents) == 1
|
||||
desc = contents[0]
|
||||
# AC-1: verbatim P0/P1 findings.
|
||||
assert "Гонка в claim_next_job: отсутствует guard в WHERE" in desc
|
||||
assert "Нет обработки OSError при чтении отчёта" in desc
|
||||
# P2 must not leak.
|
||||
assert "Переименовать blocks" not in desc
|
||||
# AC-3: link to full file preserved.
|
||||
assert "docs/work-items/ET-001/12-review.md" in desc
|
||||
|
||||
def test_tc10_tester_embeds_reason_excerpt_and_link(self, monkeypatch, tmp_path):
|
||||
"""TC-10: tester FAIL -> task_desc has reason + report excerpt + link."""
|
||||
monkeypatch.setattr(
|
||||
stage_engine, "QG_CHECKS",
|
||||
{**stage_engine.QG_CHECKS,
|
||||
"check_tests_passed": _fail("1 test failed")},
|
||||
)
|
||||
self._patch_worktree(
|
||||
monkeypatch, tmp_path, "ET-001", "13-test-report.md", _REPORT_MD
|
||||
)
|
||||
task_id = _make_task("testing")
|
||||
advance_stage(task_id, "testing", "enduro-trails", "ET-001",
|
||||
"feature/ET-001-x", finished_agent="tester")
|
||||
contents = _job_contents()
|
||||
assert len(contents) == 1
|
||||
desc = contents[0]
|
||||
# AC-2: gate reason present.
|
||||
assert "1 test failed" in desc
|
||||
# AC-2: report body excerpt (pytest output) present.
|
||||
assert "FAILED tests/test_stage_engine.py::TestTaskDescEmbedding" in desc
|
||||
# AC-3: link to full file preserved.
|
||||
assert "docs/work-items/ET-001/13-test-report.md" in desc
|
||||
|
||||
def test_tc11_reviewer_graceful_fallback_when_no_file(self, monkeypatch, tmp_path):
|
||||
"""TC-11: missing/broken 12-review.md -> graceful link-only fallback (AC-4)."""
|
||||
monkeypatch.setattr(
|
||||
stage_engine, "QG_CHECKS",
|
||||
{**stage_engine.QG_CHECKS,
|
||||
"check_reviewer_verdict": _fail("verdict: REQUEST_CHANGES")},
|
||||
)
|
||||
# Worktree resolves but the review file does not exist.
|
||||
monkeypatch.setattr(
|
||||
stage_engine, "get_worktree_path", lambda repo, branch: str(tmp_path)
|
||||
)
|
||||
task_id = _make_task("review")
|
||||
res = advance_stage(task_id, "review", "enduro-trails", "ET-001",
|
||||
"feature/ET-001-x", finished_agent="reviewer")
|
||||
# Rollback still happens exactly as before.
|
||||
assert res.rolled_back_to == "development"
|
||||
assert _stage(task_id) == "development"
|
||||
contents = _job_contents()
|
||||
assert len(contents) == 1
|
||||
desc = contents[0]
|
||||
# Falls back to the previous link-string behavior (no findings block).
|
||||
assert "Fix findings in docs/work-items/ET-001/12-review.md" in desc
|
||||
assert "Findings (P0/P1):" not in desc
|
||||
|
||||
def test_tc11_tester_graceful_fallback_keeps_reason(self, monkeypatch, tmp_path):
|
||||
"""AC-2/AC-4: missing report -> reason still present, link fallback."""
|
||||
monkeypatch.setattr(
|
||||
stage_engine, "QG_CHECKS",
|
||||
{**stage_engine.QG_CHECKS,
|
||||
"check_tests_passed": _fail("2 tests failed")},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
stage_engine, "get_worktree_path", lambda repo, branch: str(tmp_path)
|
||||
)
|
||||
task_id = _make_task("testing")
|
||||
advance_stage(task_id, "testing", "enduro-trails", "ET-001",
|
||||
"feature/ET-001-x", finished_agent="tester")
|
||||
desc = _job_contents()[0]
|
||||
assert "2 tests failed" in desc
|
||||
assert "docs/work-items/ET-001/13-test-report.md" in desc
|
||||
|
||||
def test_tc12_retry_and_rollback_behavior_unchanged(self, monkeypatch, tmp_path):
|
||||
"""TC-12 (AC-6): embedding does not change retry/rollback semantics.
|
||||
|
||||
4th developer attempt still alerts instead of enqueueing, even with a
|
||||
valid review file present.
|
||||
"""
|
||||
monkeypatch.setattr(
|
||||
stage_engine, "QG_CHECKS",
|
||||
{**stage_engine.QG_CHECKS,
|
||||
"check_reviewer_verdict": _fail("verdict: REQUEST_CHANGES")},
|
||||
)
|
||||
self._patch_worktree(monkeypatch, tmp_path, "ET-001", "12-review.md", _REVIEW_MD)
|
||||
task_id = _make_task("review")
|
||||
_add_developer_runs(task_id, 3) # already at the cap
|
||||
res = advance_stage(task_id, "review", "enduro-trails", "ET-001",
|
||||
"feature/ET-001-x", finished_agent="reviewer")
|
||||
assert res.rolled_back_to == "development"
|
||||
assert res.alerted is True
|
||||
assert stage_engine.send_telegram.called
|
||||
# No new developer job past the cap, regardless of embedding.
|
||||
assert _jobs() == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BUG 8: deploy verdict gates deploy -> done (not the LLM exit code)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user