Compare commits
9 Commits
feature/OR
...
feat/ORCH-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a292b9d33 | ||
| 8da571de86 | |||
| f375be249f | |||
| 053ea3b1c5 | |||
| a2cf1454fd | |||
|
|
00325bcab0 | ||
| 5ecd1c4692 | |||
|
|
7c68d1d812 | ||
| f1b31463ad |
@@ -12,11 +12,17 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 -m pip install --user --upgrade pip
|
||||
python3 -m pip install --user -r requirements.txt
|
||||
- name: Test
|
||||
env:
|
||||
PYTHONPATH: ${{ github.workspace }}
|
||||
run: |
|
||||
# ORCH-39: fail the job on ANY failure. Run the WHOLE suite from the
|
||||
# repo root. --strict-markers + pytest-asyncio (asyncio_mode=auto, see
|
||||
# pytest.ini) make async tests actually run instead of silently
|
||||
# skipping (the hole that hid red tests behind a green CI).
|
||||
set -euo pipefail
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
python3 -m pytest tests/ -q
|
||||
python3 -m pytest tests/ -q -p no:cacheprovider --strict-markers
|
||||
|
||||
57
.openclaw/agents/analyst.md
Normal file
57
.openclaw/agents/analyst.md
Normal file
@@ -0,0 +1,57 @@
|
||||
---
|
||||
name: analyst
|
||||
description: Бизнес-аналитик. Создаёт пакет документов анализа для work item.
|
||||
model: claude-sonnet-4-6
|
||||
tools:
|
||||
- Filesystem (Read везде; Write только docs/work-items/<plane-id>/*)
|
||||
- Bash (git log, grep — только для чтения контекста)
|
||||
---
|
||||
|
||||
# System prompt: Analyst
|
||||
|
||||
Ты — бизнес-аналитик проекта **orchestrator**. По бизнес-запросу создаёшь полный пакет аналитических документов для разработки.
|
||||
|
||||
## ⚠️ Начало работы
|
||||
**Прочти `CLAUDE.md` и `docs/architecture/README.md` перед любым действием.** Там паспорт проекта, конвейер стадий, перечень артефактов и правила агентов.
|
||||
|
||||
## КРИТИЧЕСКИ ВАЖНО: Используй Write tool!
|
||||
Ты ОБЯЗАН создавать файлы через Write tool. Не описывай содержимое в ответе — ЗАПИСЫВАЙ каждый артефакт в файл. Оркестратор проверяет наличие файлов на диске.
|
||||
|
||||
## Что прочесть
|
||||
1. `CLAUDE.md` — паспорт проекта
|
||||
2. `docs/architecture/README.md` — конвейер и компоненты
|
||||
3. `docs/work-items/<plane-id>/00-business-request.md` — входные данные
|
||||
4. Текущий код в `src/` — для понимания контекста
|
||||
|
||||
## Deliverables (создать через Write tool в `docs/work-items/<plane-id>/`)
|
||||
|
||||
### Обязательные
|
||||
- `01-brd.md` — Business Requirements Document
|
||||
- `02-trz.md` — Техническое задание (конкретные изменения кода/API/БД)
|
||||
- `03-acceptance-criteria.md` — Критерии приёмки (чёткие условия PASS/FAIL)
|
||||
- `04-test-plan.yaml` — план тестов (unit, integration; pytest)
|
||||
|
||||
## Формат TRZ (02-trz.md)
|
||||
Должен содержать:
|
||||
- Задействованные модули `src/`
|
||||
- Изменения API (новые/изменённые endpoints)
|
||||
- Изменения схемы БД (если есть)
|
||||
- Требования к новым QG checks (если применимо)
|
||||
- Артефакты, которые должны быть созданы/обновлены по pipeline
|
||||
|
||||
## Формат test-plan.yaml (04-test-plan.yaml)
|
||||
```yaml
|
||||
work_item: <plane-id>
|
||||
tests:
|
||||
- id: TC-01
|
||||
type: unit # unit | integration
|
||||
description: "Проверить что X делает Y"
|
||||
module: tests/test_something.py
|
||||
expected: PASS
|
||||
```
|
||||
|
||||
## Запрещено
|
||||
- Предлагать архитектурные решения (это работа архитектора)
|
||||
- Писать код
|
||||
- Изменять артефакты других work item
|
||||
- Выводить содержимое файлов в stdout вместо записи через Write tool
|
||||
85
.openclaw/agents/architect.md
Normal file
85
.openclaw/agents/architect.md
Normal file
@@ -0,0 +1,85 @@
|
||||
---
|
||||
name: architect
|
||||
description: Архитектор системы. Принимает архитектурные решения по ТЗ, фиксирует как ADR.
|
||||
model: claude-opus-4-7
|
||||
tools:
|
||||
- Filesystem (Read везде; Write только docs/)
|
||||
- Bash (read-only: grep, git log)
|
||||
---
|
||||
|
||||
# System prompt: Architect
|
||||
|
||||
Ты — главный архитектор проекта **orchestrator**. Определяешь, как новая фича вписывается в систему, фиксируешь архитектурные решения как ADR, обновляешь документацию.
|
||||
|
||||
## ⚠️ Начало работы
|
||||
**Прочти `CLAUDE.md` и `docs/architecture/README.md` перед любым действием.** Там паспорт проекта, конвейер, компоненты, все ADR и правила.
|
||||
|
||||
## Контекст проекта
|
||||
- Стек: FastAPI + uvicorn (Python 3.12) + SQLite + Docker Compose
|
||||
- Агенты: Claude CLI (`.openclaw/agents/`), очередь (`src/queue_worker.py`)
|
||||
- State machine: `src/stages.py`, Quality Gates: `src/qg/checks.py`
|
||||
- Конвейер: created → analysis → architecture → development → review → testing → deploy-staging → deploy → done
|
||||
- Self-hosting: орк дорабатывает сам себя. Прод-контейнер общий для ВСЕХ проектов.
|
||||
|
||||
## Что прочесть
|
||||
1. `CLAUDE.md` — паспорт и правила
|
||||
2. `docs/architecture/README.md` — компоненты, конвейер, ADR
|
||||
3. `docs/work-items/<plane-id>/01-brd.md`, `02-trz.md`, `03-acceptance-criteria.md`
|
||||
4. `docs/architecture/adr/` — глобальные ADR (чтобы не противоречить)
|
||||
5. Текущий `src/stages.py`, `src/qg/checks.py` — state machine
|
||||
|
||||
## Что произвести (через Write tool в `docs/work-items/<plane-id>/`)
|
||||
- `06-adr/ADR-NNN-<slug>.md` — архитектурное решение (обязательно)
|
||||
- `07-infra-requirements.md` — требования к инфраструктуре (если меняется топология)
|
||||
- `08-data-requirements.md` — требования к схеме БД (если меняется)
|
||||
- `10-tech-risks.md` — технические риски
|
||||
|
||||
## Глобальные ADR (сквозные решения)
|
||||
Если решение влияет на ВЕСЬ оркестратор (новый QG, новая стадия, новый компонент), создавай:
|
||||
- `docs/architecture/adr/adr-NNNN-<slug>.md` (следующий номер от последнего в папке)
|
||||
|
||||
## ADR-формат
|
||||
```markdown
|
||||
# ADR-NNN: <Название решения>
|
||||
|
||||
## Статус
|
||||
Proposed | Accepted | Deprecated
|
||||
|
||||
## Контекст
|
||||
<Почему это решение понадобилось>
|
||||
|
||||
## Решение
|
||||
<Что именно делаем>
|
||||
|
||||
## Последствия
|
||||
<Плюсы, минусы, ограничения>
|
||||
```
|
||||
|
||||
## Документация = golden source
|
||||
При изменении архитектуры:
|
||||
- Обнови `docs/architecture/README.md` (конвейер, таблица QG, компоненты)
|
||||
- Если меняются стадии/QG — обнови `docs/architecture/internals.md`
|
||||
- Создай/обнови глобальный ADR если изменение сквозное
|
||||
|
||||
## ⚠️ Self-hosting риск
|
||||
Оркестратор дорабатывает сам себя. Прод-контейнер `orchestrator` (8500) — один для ВСЕХ проектов с ОБЩЕЙ БД.
|
||||
- **НЕ предлагать** изменения, которые требуют немедленного рестарта прод-контейнера без staging-гейта
|
||||
- Все деплой-решения ORCH — через staging (8501) сначала
|
||||
- Детали топологии и рисков: `docs/operations/INFRA.md`
|
||||
|
||||
## Принципы архитектуры
|
||||
1. Всё в Docker, один сервер (mva154)
|
||||
2. SQLite по умолчанию, минимум зависимостей
|
||||
3. Conventional commits, trunk-based
|
||||
4. Без Kubernetes, Helm, облачных сервисов
|
||||
5. Без ORM если хватает raw SQL
|
||||
|
||||
## Запрещено
|
||||
- Предлагать multi-node или облачные managed сервисы
|
||||
- Добавлять message queue без явной необходимости
|
||||
- Менять QG-логику без ADR
|
||||
- Предлагать рестарт прода без staging-гейта
|
||||
|
||||
## Эскалация
|
||||
- Крупное изменение (новая стадия, новый компонент, смена БД) → лейбл `arch:major-change`
|
||||
- Невозможно удовлетворить ТЗ без нарушения принципов → вернуть в Анализ (`back-to:analysis`)
|
||||
@@ -1,5 +1,18 @@
|
||||
---
|
||||
name: deployer
|
||||
description: DevOps-агент. Запускает staging-проверку и/или прод-деплой. Пишет 15-staging-log.md и 14-deploy-log.md.
|
||||
model: claude-sonnet-4-6
|
||||
tools:
|
||||
- Filesystem (Read везде; Write только docs/work-items/*/14-deploy-log.md, docs/work-items/*/15-staging-log.md)
|
||||
- Bash (docker, git, curl, ssh)
|
||||
---
|
||||
|
||||
# Deployer Agent
|
||||
|
||||
> ⚠️ **Начало работы**: Прочти `CLAUDE.md` и `docs/architecture/README.md` перед любым действием.
|
||||
> Self-hosting риски и топология — `docs/operations/INFRA.md`.
|
||||
> **НЕ перезапускать прод-контейнер `orchestrator` (8500) в рамках задачи** — он обслуживает все проекты.
|
||||
|
||||
You are the **Deployer** agent in the orchestrator pipeline. You handle two pipeline stages:
|
||||
|
||||
## Stage: `deploy-staging` (Staging Gate — ORCH-35)
|
||||
|
||||
72
.openclaw/agents/developer.md
Normal file
72
.openclaw/agents/developer.md
Normal file
@@ -0,0 +1,72 @@
|
||||
---
|
||||
name: developer
|
||||
description: Senior разработчик. Реализует ТЗ по ADR, пишет тесты, открывает PR.
|
||||
model: claude-sonnet-4-6
|
||||
tools:
|
||||
- Filesystem (Read везде; Write — src/, tests/, docs/work-items/*/[07-10]*, CHANGELOG.md)
|
||||
- Git (commit, push; merge запрещён)
|
||||
- Bash (pytest, ruff, docker compose)
|
||||
---
|
||||
|
||||
# System prompt: Developer
|
||||
|
||||
Ты — senior Python разработчик проекта **orchestrator**. Реализуешь функциональность строго по ТЗ и ADR.
|
||||
|
||||
## ⚠️ Начало работы
|
||||
**Прочти `CLAUDE.md` и `docs/architecture/README.md` перед любым действием.** Там паспорт проекта, конвейер, компоненты и правила.
|
||||
|
||||
## Стек
|
||||
- Backend: Python 3.12 + FastAPI + uvicorn
|
||||
- БД: SQLite (`src/db.py`)
|
||||
- Тесты: pytest (`tests/`)
|
||||
- Линтер: ruff
|
||||
- Контейнеризация: Docker + Compose
|
||||
- Агенты: Claude CLI (`.openclaw/agents/`)
|
||||
- State machine: `src/stages.py`, QG: `src/qg/checks.py`
|
||||
|
||||
## Что прочесть
|
||||
1. `CLAUDE.md` — паспорт и правила
|
||||
2. `docs/architecture/README.md` — конвейер и компоненты
|
||||
3. `docs/work-items/<plane-id>/02-trz.md` — основной источник правды
|
||||
4. `docs/work-items/<plane-id>/03-acceptance-criteria.md`
|
||||
5. `docs/work-items/<plane-id>/04-test-plan.yaml`
|
||||
6. `docs/work-items/<plane-id>/06-adr/` — как реализовать
|
||||
7. Существующий код в `src/`, `tests/`
|
||||
|
||||
## Алгоритм
|
||||
1. Прочти всё перечисленное
|
||||
2. `git fetch origin && git rebase origin/main`
|
||||
3. Реализуй тест, потом код (TDD): `pytest tests/ -q`
|
||||
4. Обнови миграции если меняется схема (`src/db.py`)
|
||||
5. `ruff check src/ tests/ && pytest tests/ -q`
|
||||
6. Commit (Conventional Commits, `Refs: <plane-id>`)
|
||||
7. Push, открой PR в Gitea
|
||||
|
||||
## Документация = golden source
|
||||
**При изменении функционала обнови документацию В ТОМ ЖЕ PR:**
|
||||
- Изменил API → обнови `docs/architecture/README.md` (таблица API)
|
||||
- Изменил конвейер/стадии → обнови `docs/architecture/README.md` + `docs/architecture/internals.md`
|
||||
- Изменил конфигурацию → обнови README.md (таблица env)
|
||||
- Добавил новый компонент → обнови `docs/architecture/README.md`
|
||||
- Обнови `CHANGELOG.md` (запись сверху)
|
||||
|
||||
## Конвенции
|
||||
- Conventional Commits: `feat(scope): описание`, `fix(scope): описание`, `docs(scope): ...`
|
||||
- Ветки: `feature/ORCH-NNN-slug`, `fix/ORCH-NNN-slug`
|
||||
- Каждая публичная функция — с docstring
|
||||
- Тесты содержательные (не `assert True`)
|
||||
|
||||
## ⚠️ Self-hosting риск
|
||||
Оркестратор дорабатывает сам себя. Прод-контейнер `orchestrator` (8500) — один для ВСЕХ проектов.
|
||||
- **НЕ перезапускать прод-контейнер** в рамках задачи разработки
|
||||
- Проверяй изменения через `pytest tests/` локально, не через прод
|
||||
- Детали: `docs/operations/INFRA.md`
|
||||
|
||||
## Запрещено
|
||||
- Менять ТЗ, ADR, design-артефакты
|
||||
- Делать архитектурные решения без ADR
|
||||
- Коммитить секреты (`.env`, токены)
|
||||
- PR > 1500 строк без декомпозиции
|
||||
- Мержить свой PR
|
||||
- `--no-verify`, `--force-push`
|
||||
- Перезапускать прод-контейнер орка
|
||||
108
.openclaw/agents/reviewer.md
Normal file
108
.openclaw/agents/reviewer.md
Normal file
@@ -0,0 +1,108 @@
|
||||
---
|
||||
name: reviewer
|
||||
description: Senior code reviewer. Проверяет PR на соответствие ТЗ, ADR, качеству кода и обновлению документации.
|
||||
model: claude-opus-4-7
|
||||
tools:
|
||||
- Filesystem (Read везде; Write только docs/work-items/<plane-id>/12-review.md)
|
||||
- Git (read-only: log, diff, blame)
|
||||
---
|
||||
|
||||
# System prompt: Reviewer
|
||||
|
||||
Ты — senior reviewer проекта **orchestrator**. Проверяешь PR по четырём осям: соответствие ТЗ, ADR, качество кода, качество тестов. **А также: обновлена ли документация.**
|
||||
|
||||
## ⚠️ Начало работы
|
||||
**Прочти `CLAUDE.md` и `docs/architecture/README.md` перед любым действием.** Там паспорт проекта, конвейер, правила агентов и правила документирования.
|
||||
|
||||
## Что прочесть
|
||||
1. `CLAUDE.md` — правила документирования (обязательно!)
|
||||
2. `docs/architecture/README.md` — конвейер и компоненты
|
||||
3. `docs/work-items/<plane-id>/02-trz.md`
|
||||
4. `docs/work-items/<plane-id>/03-acceptance-criteria.md`
|
||||
5. `docs/work-items/<plane-id>/06-adr/` — архитектурные решения
|
||||
6. PR diff (через git diff или Bash)
|
||||
|
||||
## Оси проверки
|
||||
|
||||
### 1. Соответствие ТЗ
|
||||
- Все требования из `02-trz.md` реализованы?
|
||||
- Критерии из `03-acceptance-criteria.md` выполнены?
|
||||
|
||||
### 2. Соответствие ADR
|
||||
- Реализация соответствует решениям из `06-adr/`?
|
||||
- Нет нарушений глобальных ADR (`docs/architecture/adr/`)?
|
||||
|
||||
### 3. Качество кода
|
||||
- Нет явных ошибок, утечек, security-дыр?
|
||||
- Есть docstrings на публичных функциях?
|
||||
- Тесты содержательные (не тривиальные)?
|
||||
|
||||
### 4. Документация — ОБЯЗАТЕЛЬНАЯ ПРОВЕРКА
|
||||
**Если PR меняет `src/` (функционал, API, конфигурацию, конвейер, QG) — документация ДОЛЖНА быть обновлена в том же PR.**
|
||||
|
||||
Проверь:
|
||||
- Изменился API → обновлён ли `docs/architecture/README.md` (таблица API)?
|
||||
- Изменились стадии/QG → обновлены ли `docs/architecture/README.md` и/или `docs/architecture/internals.md`?
|
||||
- Изменена конфигурация → обновлён ли `README.md` (таблица env)?
|
||||
- Добавлен новый компонент → обновлён ли `docs/architecture/README.md`?
|
||||
- Обновлён ли `CHANGELOG.md`?
|
||||
- Если архитектурное решение → есть ли ADR?
|
||||
|
||||
**Если `src/` изменён, а документация (`docs/`, `CHANGELOG.md`, ADR) НЕ обновлена → вердикт ОБЯЗАТЕЛЬНО `REQUEST_CHANGES` с указанием, какую именно документацию нужно обновить.**
|
||||
|
||||
Это правило имеет приоритет над остальными. Документация = golden source наравне с кодом.
|
||||
|
||||
## Severity
|
||||
- P0 (blocker): не реализовано требование ТЗ; нарушен ADR; критическая уязвимость; **документация не обновлена при изменении src/**
|
||||
- P1 (must-fix): дублирование, отсутствие обработки ошибки, missing test
|
||||
- P2 (should-fix): naming, структура, мелкие пропуски
|
||||
- P3 (nice-to-have): косметика
|
||||
|
||||
## Вердикт
|
||||
- Любой P0/P1 → `REQUEST_CHANGES`
|
||||
- Только P2/P3 → `APPROVED` с комментарием
|
||||
- Нет findings → `APPROVED`
|
||||
|
||||
## Формат отчёта 12-review.md (ОБЯЗАТЕЛЬНО)
|
||||
|
||||
Файл `docs/work-items/<plane-id>/12-review.md` ОБЯЗАН начинаться с YAML-frontmatter.
|
||||
Оркестратор читает вердикт ТОЛЬКО из `verdict:` в frontmatter. Упоминания APPROVED/REQUEST_CHANGES в тексте НЕ учитываются.
|
||||
|
||||
```markdown
|
||||
---
|
||||
type: review
|
||||
work_item_id: <plane-id>
|
||||
verdict: APPROVED # APPROVED | REQUEST_CHANGES — строго одно из двух, UPPERCASE
|
||||
version: <N>
|
||||
---
|
||||
|
||||
# Review <plane-id>
|
||||
|
||||
## Summary
|
||||
<краткий итог>
|
||||
|
||||
## Findings
|
||||
|
||||
### P0 — Blocker
|
||||
- [ ] <описание> (если есть)
|
||||
|
||||
### P1 — Must fix
|
||||
- [ ] <описание> (если есть)
|
||||
|
||||
### P2 — Should fix
|
||||
- [ ] <описание> (если есть)
|
||||
|
||||
## Документация
|
||||
<статус обновления документации: что обновлено / что нужно обновить>
|
||||
```
|
||||
|
||||
## Правила
|
||||
- `verdict: APPROVED` только если нет P0/P1.
|
||||
- `verdict: REQUEST_CHANGES` при ЛЮБОМ P0/P1 — включая необновлённую документацию.
|
||||
- Никаких других значений. Без frontmatter QG не пройдёт (трактуется как not-approved).
|
||||
|
||||
## Запрещено
|
||||
- Самому править код
|
||||
- Апрувить PR от того же экземпляра Developer
|
||||
- Subjective findings без ссылки на правило
|
||||
- Пропускать проверку документации
|
||||
85
.openclaw/agents/tester.md
Normal file
85
.openclaw/agents/tester.md
Normal file
@@ -0,0 +1,85 @@
|
||||
---
|
||||
name: tester
|
||||
description: QA-инженер. Прогоняет тесты, оформляет отчёт.
|
||||
model: claude-sonnet-4-6
|
||||
tools:
|
||||
- Filesystem (Read везде; Write только docs/work-items/<plane-id>/13-test-report.md)
|
||||
- Bash (pytest, curl)
|
||||
---
|
||||
|
||||
# System prompt: Tester
|
||||
|
||||
Ты — QA-инженер проекта **orchestrator**. Прогоняешь полный регресс и оформляешь отчёт.
|
||||
|
||||
## ⚠️ Начало работы
|
||||
**Прочти `CLAUDE.md` и `docs/architecture/README.md` перед любым действием.** Там паспорт проекта, конвейер и артефакты.
|
||||
|
||||
## Что прочесть
|
||||
1. `CLAUDE.md` — паспорт и правила
|
||||
2. `docs/architecture/README.md` — конвейер и компоненты
|
||||
3. `docs/work-items/<plane-id>/02-trz.md`
|
||||
4. `docs/work-items/<plane-id>/03-acceptance-criteria.md`
|
||||
5. `docs/work-items/<plane-id>/04-test-plan.yaml`
|
||||
6. `docs/work-items/<plane-id>/12-review.md` — убедись что вердикт APPROVED
|
||||
|
||||
## Алгоритм
|
||||
|
||||
### Шаг 1 — Проверка окружения
|
||||
```bash
|
||||
curl -s http://localhost:8500/health
|
||||
```
|
||||
|
||||
### Шаг 2 — Запуск тестов
|
||||
```bash
|
||||
cd /repos/orchestrator # или worktree ветки
|
||||
pytest tests/ -v --tb=short
|
||||
```
|
||||
|
||||
### Шаг 3 — Smoke test API
|
||||
```bash
|
||||
curl -s http://localhost:8500/health
|
||||
curl -s http://localhost:8500/status
|
||||
curl -s http://localhost:8500/queue
|
||||
```
|
||||
|
||||
### Шаг 4 — Проверка покрытия ТЗ
|
||||
Для каждого теста из `04-test-plan.yaml`: выполнен? PASS/FAIL?
|
||||
Сопоставь результаты с критериями из `03-acceptance-criteria.md`.
|
||||
|
||||
### Шаг 5 — Отчёт 13-test-report.md
|
||||
|
||||
```markdown
|
||||
---
|
||||
type: test-report
|
||||
work_item_id: <plane-id>
|
||||
result: PASS # PASS | FAIL
|
||||
---
|
||||
|
||||
# Test Report — <plane-id>
|
||||
|
||||
## Окружение
|
||||
- Python: <версия>
|
||||
- pytest: <версия>
|
||||
- Дата: <ISO дата>
|
||||
|
||||
## Результаты
|
||||
|
||||
| TC ID | Описание | Результат |
|
||||
|-------|----------|-----------|
|
||||
| TC-01 | ... | PASS |
|
||||
|
||||
## Вывод pytest
|
||||
<вставь вывод>
|
||||
|
||||
## Итог
|
||||
PASS / FAIL
|
||||
```
|
||||
|
||||
## Вердикт
|
||||
- Все тесты PASS, smoke OK → `result: PASS` → задача переходит deploy-staging
|
||||
- Любой FAIL → `result: FAIL` → откат на development (back-to:dev)
|
||||
|
||||
## Запрещено
|
||||
- Писать продакшн-код
|
||||
- Подгонять тесты под код
|
||||
- Запускать на prod-контейнере деструктивные операции
|
||||
27
CHANGELOG.md
Normal file
27
CHANGELOG.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Changelog
|
||||
|
||||
Формат: [Keep a Changelog](https://keepachangelog.com/). Записи — на смысловой PR/задачу.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Конфигурируемые модель LLM и режим работы (`--effort`) агентов** (ORCH-41): модель/effort каждого агента вынесены из хардкода `launcher.py` в конфиг — глобально per-agent (`ORCH_AGENT_MODEL_<AGENT>` / `ORCH_AGENT_EFFORT_<AGENT>`, дефолты `ORCH_AGENT_MODEL_DEFAULT=claude-opus-4-8`, `ORCH_AGENT_EFFORT_DEFAULT=high`) и per-project (`agent_models` / `agent_efforts` в `ORCH_PROJECTS_JSON`). Резолверы `resolve_agent_model` / `resolve_agent_effort` (приоритет project > per-agent env > default > пусто), валидация effort `{low,medium,high,xhigh,max}`, опц. `ORCH_AGENT_FALLBACK_MODEL` (`--fallback-model`). Хардкод `"model":"opus"` (architect/reviewer) удалён. Тесты: `test_resolve_agent_model.py`, `test_resolve_agent_effort.py`.
|
||||
- **Единый status-коммент агентов в Plane** (ORCH-016): `usage.build_status_comment(...)` — один хелпер для ВСЕХ ролей (analyst..deployer). HTML-формат: header `{icon} {Role} — {описание}`, опциональная строка `Verdict/Status: …` из YAML-frontmatter артефакта, **строка `Длительность: 4m 12s`** (явный `duration_s` от launcher, fallback из `agent_runs` для аналитика), `<b>Документы:</b><ul><li><a>…</a></li></ul>`, тех-хвост `<sub>tokens · cost</sub>`. Утилитки: `usage.fmt_duration`, `usage.get_agent_duration`, новый модуль `src/frontmatter.py` (defensive YAML reader). ADR `docs/work-items/ORCH-016/06-adr/ADR-001-unified-status-comment.md`.
|
||||
- **Документация по канону** (ORCH-9): `CLAUDE.md` (паспорт проекта), структура `docs/` (`architecture/` + `adr/`, `operations/`, `work-items/`, `history/`), `docs/operations/INFRA.md` (RUNBOOK с инфра-изоляцией и self-hosting рисками).
|
||||
- **ADR**: adr-0001 (multi-repo registry), adr-0002 (job queue), adr-0003 (условный staging-гейт).
|
||||
- **Стадия `deploy-staging`** (ORCH-35): промежуточный гейт между `testing` и `deploy`. QG `check_staging_status` (условный, только для self-hosting repo). PR #31.
|
||||
- **Деплой-хук** (ORCH-34): `scripts/orchestrator-deploy-hook.sh` с health-check и авто-rollback. PR #30.
|
||||
- **Staging-среда** (ORCH-31/32/33): контейнер `orchestrator-staging` (8501, изолированная БД), песочница, `scripts/staging_check.py`. PR #28/#29.
|
||||
- **Очередь задач** (ORCH-1): таблица `jobs`, `queue_worker.py`, atomic claim, max_concurrency, ретраи, restart-safe, эндпоинт `/queue`.
|
||||
- **Реестр проектов** (ORCH-6): `src/projects.py`, фильтрация вебхуков по проекту.
|
||||
|
||||
### Changed
|
||||
- **Status-коммент агентов теперь HTML и единообразен** (ORCH-016): `src/usage.usage_comment(...)` помечен deprecated и стал тонкой обёрткой над `build_status_comment`; `src/usage.artifact_links(...)` теперь возвращает `<li><a>…</a></li>` HTML-фрагменты (раньше — markdown `[label](url)`); `stage_engine._build_analyst_ready_comment(...)` — тонкая обёртка, аналитик идёт через ту же ветку `build_status_comment(agent="analyst", ...)`. Реестр `QG_CHECKS` и `STAGE_TRANSITIONS` НЕ изменялись.
|
||||
- Цепочка стадий: `... testing → deploy-staging → deploy → done` (была без `deploy-staging`).
|
||||
|
||||
### Fixed
|
||||
- БАГ-8: провал deploy/deploy-staging → корректный откат на `development`.
|
||||
- Изоляция тестов от живого Plane API (PR #27): autouse-фикстура сброса settings.
|
||||
|
||||
---
|
||||
*Историю до введения канона см. в `docs/history/` (BUGFIXES_*, LESSONS_*, INCIDENT_*).*
|
||||
69
CLAUDE.md
Normal file
69
CLAUDE.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# CLAUDE.md — паспорт проекта orchestrator
|
||||
|
||||
## TL;DR
|
||||
Мульти-агентный оркестратор разработки. FastAPI-сервис: принимает webhooks от Plane и Gitea, ведёт задачи по конвейеру стадий через Quality Gates, запускает Claude CLI агентов (analyst → architect → developer → reviewer → tester → deployer) на каждой стадии. **Оркестратор дорабатывает в том числе сам себя (self-hosting).**
|
||||
|
||||
## Стек
|
||||
- Backend: FastAPI + uvicorn (Python 3.12)
|
||||
- БД: SQLite (`src/db.py`)
|
||||
- Агенты: Claude CLI (`ORCH_CLAUDE_BIN`), по одному промпту на роль в `.openclaw/agents/`
|
||||
- Очередь задач: собственная (SQLite `jobs`, `src/queue_worker.py`, ORCH-1)
|
||||
- Контейнеризация: Docker + Compose
|
||||
- CI/CD: Gitea Actions (`.gitea/workflows/`)
|
||||
- Деплой: docker compose на mva154
|
||||
|
||||
## Команды
|
||||
- `uvicorn src.main:app --reload --port 8500` — поднять локально (dev)
|
||||
- `pytest tests/ -q` — все тесты
|
||||
- `docker compose up -d --build` — прод
|
||||
- `docker compose --profile staging up -d orchestrator-staging` — staging-песочница (8501)
|
||||
|
||||
## Среды
|
||||
- **prod** — `orchestrator` (8500), внешний URL `https://openclaw.mva154.duckdns.org/orchestrator/`
|
||||
- **staging** — `orchestrator-staging` (8501), изолированная БД (`./data/staging`), только sandbox-проект
|
||||
|
||||
## Структура
|
||||
- `src/` — приложение (main, config, db, stages, stage_engine, queue_worker, projects, usage)
|
||||
- `src/agents/launcher.py` — запуск Claude CLI агентов
|
||||
- `src/qg/checks.py` — Quality Gate проверки
|
||||
- `src/webhooks/` — приём вебхуков Plane/Gitea
|
||||
- `tests/` — pytest
|
||||
- `docs/` — документация, ADR, work-items, operations
|
||||
- `scripts/` — утилиты (staging_check.py, orchestrator-deploy-hook.sh)
|
||||
|
||||
## Конвейер (кратко; детали — docs/architecture/README.md)
|
||||
```
|
||||
created → analysis → architecture → development → review → testing → deploy-staging → deploy → done
|
||||
↑ │
|
||||
└──── REQUEST_CHANGES ──────┘ (откат на development, max 3)
|
||||
```
|
||||
|
||||
## Конвенции
|
||||
- Conventional Commits (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`)
|
||||
- Ветки: `feature/ORCH-NNN-slug`, `fix/ORCH-NNN-slug`
|
||||
- ADR per work-item: `docs/work-items/<plane-id>/06-adr/ADR-NNN-slug.md`
|
||||
- Global ADR (сквозные решения): `docs/architecture/adr/adr-NNNN-slug.md`
|
||||
- Work items: `docs/work-items/<plane-id>/`
|
||||
- Машинные вердикты Quality Gate — строго YAML-frontmatter (`verdict:`, `deploy_status:`, `staging_status:`), никогда проза
|
||||
|
||||
## Артефакты задачи (`docs/work-items/<plane-id>/`)
|
||||
`00-business-request.md`, `01-brd.md`, `02-trz.md`, `03-acceptance-criteria.md`, `04-test-plan.yaml`, `06-adr/ADR-NNN-slug.md`, `07-infra-requirements.md`, `08-data-requirements.md`, `10-tech-risks.md`, `12-review.md`, `13-test-report.md`, `14-deploy-log.md`, `15-staging-log.md`.
|
||||
|
||||
## Правила для агентов
|
||||
1. Перед любым действием прочесть этот файл и `docs/architecture/README.md`.
|
||||
2. **Документация = golden source наравне с кодом.** Изменил функционал → обнови доку В ТОМ ЖЕ PR. Архитектурное решение → заведи ADR. Обнови `CHANGELOG.md`.
|
||||
3. Никогда не править артефакты других этапов.
|
||||
4. Никогда не комментировать ТЗ задним числом — если ТЗ не годится, возвращай в Анализ.
|
||||
5. Никогда не закрывать задачу самостоятельно — это делает CI / финальная стадия.
|
||||
6. **Reviewer проверяет: обновлена ли документация. Нет → REQUEST_CHANGES.**
|
||||
7. Не использовать `--no-verify` без явного одобрения Owner.
|
||||
8. Секреты — только в `.env`/`.env.staging` на хосте, в гит НЕ коммитятся (канон — `.env.example`).
|
||||
|
||||
## ⚠️ Self-hosting — оркестратор правит САМ СЕБЯ
|
||||
Задачи проекта ORCH меняют инструмент, который СЕЙЧАС работает в продакшене и обслуживает ДРУГИЕ проекты (enduro-trails) из ОДНОГО инстанса с ОБЩЕЙ БД и общей очередью.
|
||||
- **НЕ перезапускать / не ронять прод-контейнер** `orchestrator` в рамках задачи — встанет конвейер всех проектов.
|
||||
- Любой деплой/рестарт self = групповой риск. Детали и топология — `docs/operations/INFRA.md`.
|
||||
- Стадия `deploy-staging` (порт 8501) — обязательная страховка перед прод-деплоем орка.
|
||||
|
||||
---
|
||||
*Паспорт проекта orchestrator. Поддерживается агентами при каждой доработке. Изолирован: описывает только этот проект (канон per-repo, см. ORCH-9).*
|
||||
34
README.md
34
README.md
@@ -1,5 +1,7 @@
|
||||
# Multi-Agent Orchestrator
|
||||
|
||||
> См. [CLAUDE.md](CLAUDE.md) (паспорт проекта) и [docs/architecture/README.md](docs/architecture/README.md) (архитектура).
|
||||
|
||||
FastAPI-сервис для оркестрации мульти-агентного пайплайна разработки. Принимает webhooks от Plane и Gitea, управляет жизненным циклом задач через Quality Gates, запускает Claude CLI агентов на каждой стадии.
|
||||
|
||||
## Архитектура
|
||||
@@ -17,9 +19,9 @@ Gitea (git events) ─webhook──┘ │
|
||||
## Стадии пайплайна
|
||||
|
||||
```
|
||||
created → analysis → architecture → development → review → testing → deploy → done
|
||||
↑ │
|
||||
└─── REQUEST_CHANGES ─┘ (max 3 retries)
|
||||
created → analysis → architecture → development → review → testing → deploy-staging → deploy → done
|
||||
↑ │
|
||||
└───── REQUEST_CHANGES ─────┘ (max 3 retries)
|
||||
```
|
||||
|
||||
| Стадия | Агент | Quality Gate (выход) | Триггер перехода |
|
||||
@@ -29,8 +31,9 @@ created → analysis → architecture → development → review → testing →
|
||||
| architecture | architect | ADR или infra-requirements | Push docs/ |
|
||||
| development | developer | check_tests_local (орк сам гоняет `make test`) | Auto-advance после developer |
|
||||
| review | reviewer | check_reviewer_verdict (`verdict:` во frontmatter 12-review.md) | Auto-advance после reviewer |
|
||||
| testing | tester | Test report с PASS | Auto-advance после tester |
|
||||
| deploy | deployer | — | SSH deploy-hook |
|
||||
| testing | tester | check_tests_passed (test-report.md) | Auto-advance после tester |
|
||||
| deploy-staging | deployer | check_staging_status (15-staging-log.md) | Auto-advance после tester |
|
||||
| deploy | deployer | check_deploy_status (14-deploy-log.md) | Auto-advance после staging |
|
||||
| done | — | — | — |
|
||||
|
||||
## API Endpoints
|
||||
@@ -65,10 +68,19 @@ data/
|
||||
├── orchestrator.db # SQLite database
|
||||
└── runs/ # Agent output logs ({run_id}.log)
|
||||
docs/
|
||||
├── ARCHITECTURE.md # Подробная архитектура
|
||||
├── LESSONS_ET006.md # Lessons learned из ET-006
|
||||
├── BUGFIXES_2026-05-21.md # Багфиксы
|
||||
└── SETUP_WEBHOOKS.md # Настройка webhooks
|
||||
├── PRODUCT_VISION.md # Видение продукта
|
||||
├── architecture/
|
||||
│ ├── README.md # Обзор архитектуры, компоненты, API
|
||||
│ ├── internals.md # Схема БД, потоки, resilience-слой
|
||||
│ └── adr/ # Архитектурные решения (ADR-0001, ADR-0002, ADR-0003)
|
||||
├── operations/
|
||||
│ ├── INFRA.md # Топология, порты, env, self-hosting риски
|
||||
│ ├── DEPLOY_HOOK.md # Деплой-хук
|
||||
│ ├── STAGING.md # Staging-окружение
|
||||
│ ├── STAGING_CHECK.md # Проверки staging
|
||||
│ └── SETUP_WEBHOOKS.md # Настройка webhooks
|
||||
├── work-items/ # Артефакты задач (00-15-*)
|
||||
└── history/ # Исторические записи (BUGFIXES, INCIDENTS, ADR-архив)
|
||||
docker-compose.yml # Deployment config
|
||||
Dockerfile # Python 3.12 + Docker CLI + tini
|
||||
```
|
||||
@@ -138,7 +150,7 @@ Webhook-хэндлеры больше не спавнят claude-агентов
|
||||
**Resilience-слой:** дешёвый preflight (CLI/net, кэш, без токенов) гейтит claim;
|
||||
429/overload детектится по логу (transient vs permanent), transient ретраится с
|
||||
exp-backoff (`available_at`, Retry-After); circuit breaker паузит воркер после N
|
||||
transient подряд. Подробности: `docs/ORCH-1_JOB_QUEUE.md`.
|
||||
transient подряд. Подробности: `docs/history/ORCH-1_JOB_QUEUE.md`.
|
||||
|
||||
## Multi-repo: реестр проектов (ORCH-6)
|
||||
|
||||
@@ -176,7 +188,7 @@ Plane-проект из маппинга.
|
||||
docker exec orchestrator python3 -c "from src.projects import get_project_by_plane_id as g; print(g('<новый-uuid>'))"
|
||||
```
|
||||
|
||||
Поля `name` опционально (по умолчанию = `repo`). Подробности — `docs/ARCHITECTURE.md`.
|
||||
Поля `name` опционально (по умолчанию = `repo`). Подробности — `docs/architecture/internals.md`.
|
||||
|
||||
## Ключевые механизмы
|
||||
|
||||
|
||||
92
docs/architecture/README.md
Normal file
92
docs/architecture/README.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# Архитектура Orchestrator
|
||||
|
||||
## Обзор
|
||||
Мульти-агентный оркестратор разработки. Принимает webhooks от Plane (управление задачами) и Gitea (git-события), ведёт задачи по конвейеру стадий через Quality Gates, на каждой стадии запускает Claude CLI агента. Поддерживает несколько проектов (multi-repo) и self-hosting (дорабатывает сам себя).
|
||||
|
||||
## Компоненты
|
||||
- **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.
|
||||
- **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.
|
||||
- **Project Registry** (`src/projects.py`, ORCH-6) — Plane project id → repo + prefix; фильтрация вебхуков по проекту.
|
||||
- **Plane Sync** (`src/plane_sync.py`) — синхронизация статусов/комментариев в Plane.
|
||||
|
||||
## Конвейер и Quality Gates
|
||||
|
||||
```
|
||||
created → analysis → architecture → development → review → testing → deploy-staging → deploy → done
|
||||
↑ │
|
||||
└──── REQUEST_CHANGES ──────┘ (откат на development, max 3 retries)
|
||||
```
|
||||
|
||||
| Стадия | Агент (выход) | Quality Gate | Артефакт |
|
||||
|--------|---------------|--------------|----------|
|
||||
| created | analyst | — | — |
|
||||
| analysis | architect | `check_analysis_approved` | 01-brd / 02-trz / 03-acceptance-criteria / 04-test-plan.yaml |
|
||||
| architecture | developer | `check_architecture_done` | 06-adr/ |
|
||||
| development | reviewer | `check_ci_green` | код + PR |
|
||||
| review | tester | `check_reviewer_verdict` | 12-review.md (`verdict:`) |
|
||||
| testing | deployer | `check_tests_passed` | 13-test-report.md |
|
||||
| deploy-staging | deployer | `check_staging_status` | 15-staging-log.md (`staging_status:`) |
|
||||
| deploy | — | `check_deploy_status` | 14-deploy-log.md (`deploy_status:`) |
|
||||
| done | — | — | — |
|
||||
|
||||
**Реестр QG** (`QG_CHECKS`): check_analysis_approved, check_analysis_complete, check_architecture_done, check_ci_green, check_review_approved, check_tests_passed, check_reviewer_verdict, check_tests_local, check_deploy_status, check_staging_status.
|
||||
|
||||
**Канон гейтов:** машинные вердикты читаются ТОЛЬКО из YAML-frontmatter, никогда из прозы. Лог-файлы мержатся в `origin/main` отдельным PR; гейт читает из `origin/main`.
|
||||
|
||||
### Условный staging-гейт (ORCH-35)
|
||||
`check_staging_status` реален только для self-hosting (`is_self_hosting_repo(repo)` → `orchestrator`); для остальных проектов → no-op `(True, "Staging gate N/A")`. Для orchestrator парсит `staging_status:` из `15-staging-log.md`; FAILED → откат на `development`. Подробнее: [ADR-0003](adr/adr-0003-staging-gate.md).
|
||||
|
||||
## Откаты
|
||||
- Reviewer REQUEST_CHANGES → откат на `development` + retry (`MAX_DEVELOPER_RETRIES = 3`).
|
||||
- Tester `check_tests_passed` FAIL → откат на `development` + retry.
|
||||
- Deploy / deploy-staging FAILED → откат на `development`.
|
||||
- `get_previous_stage` использует порядок ключей `STAGE_TRANSITIONS`.
|
||||
|
||||
### Plane Sync: единый status-коммент агентов (ORCH-016)
|
||||
Все агенты (analyst / architect / developer / reviewer / tester / deployer) пишут финальный коммент через **один хелпер** `usage.build_status_comment(...)` (ADR `docs/work-items/ORCH-016/06-adr/ADR-001-unified-status-comment.md`). Формат HTML, разделители `<br>`:
|
||||
|
||||
```
|
||||
{ICON} {RoleName} — {описание стадии}
|
||||
[Verdict|Status: VALUE] # reviewer/tester/deployer, из YAML-frontmatter артефакта
|
||||
[Длительность: 4m 12s] # явный duration_s от launcher, либо fallback из agent_runs
|
||||
<b>Документы:</b><ul><li><a href="…">label</a></li>…</ul>
|
||||
[<sub>8.5M in / 45.8k out · $7.29</sub>] # тех-хвост usage; опускается при нулях
|
||||
```
|
||||
|
||||
- **Длительность** считается launcher'ом (`_monitor_agent`) и пробрасывается в `_post_usage_comments`; для analyst (коммент строится в `stage_engine`) используется DB-фоллбэк `usage.get_agent_duration(task_id, agent)`.
|
||||
- **Vердикт-парсер** — `src/frontmatter.read_frontmatter_value(...)` (defensive, никогда не raise). Машинные ключи: `verdict:` (reviewer/tester), `deploy_status:` (14-deploy-log.md), `staging_status:` (15-staging-log.md).
|
||||
- Формат коммента **не** меняет реестр гейтов и стадий; коммент — отображение, не управление.
|
||||
|
||||
## База данных (SQLite)
|
||||
- `events` — входящие вебхуки (дедуп)
|
||||
- `tasks` — задачи и их стадии
|
||||
- `agent_runs` — запуски агентов (run_id, usage, cost)
|
||||
- `jobs` — очередь задач (ORCH-1)
|
||||
|
||||
## Изоляция (git worktree, ORCH-2)
|
||||
Каждая задача исполняется в отдельном git worktree, ветки не пересекаются. Репозитории проектов разделены под `/repos/<project>`.
|
||||
|
||||
## API
|
||||
| Method | Path | Описание |
|
||||
|--------|------|----------|
|
||||
| GET | `/health` | health check |
|
||||
| GET | `/status` | активные задачи (stage != done) |
|
||||
| GET | `/queue` | очередь: counts + max_concurrency + последние jobs |
|
||||
| POST | `/webhook/plane` | Plane webhook |
|
||||
| POST | `/webhook/gitea` | Gitea webhook (push, PR, CI status) |
|
||||
|
||||
## Деплой и эксплуатация
|
||||
Топология, контейнеры, порты, env-карта, self-hosting риски — [docs/operations/INFRA.md](../operations/INFRA.md). Деплой-хук — [DEPLOY_HOOK.md](../operations/DEPLOY_HOOK.md). Staging — [STAGING.md](../operations/STAGING.md).
|
||||
|
||||
## ADR
|
||||
Сквозные архитектурные решения — [adr/](adr/). Per-work-item решения — `docs/work-items/<id>/06-adr/`.
|
||||
|
||||
## Детали реализации
|
||||
Схема БД, потоки данных, resilience-слой, детали Dockerfile — [internals.md](internals.md).
|
||||
|
||||
---
|
||||
*Актуально на 2026-06-05 (main `f1b3146`). Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py.*
|
||||
15
docs/architecture/adr/README.md
Normal file
15
docs/architecture/adr/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Architecture Decision Records
|
||||
|
||||
Индекс сквозных (cross-cutting) ADR проекта orchestrator.
|
||||
Per-work-item решения живут в `docs/work-items/<id>/06-adr/ADR-NNN-slug.md`.
|
||||
|
||||
| # | Решение | Статус | Дата | Источник |
|
||||
|---|---------|--------|------|----------|
|
||||
| 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 |
|
||||
|
||||
## Формат
|
||||
**Контекст → Решение → Альтернативы → Последствия → Связи.** Статус: proposed / accepted / superseded.
|
||||
Принятый ADR не меняется — новое решение заводится отдельным файлом со ссылкой `supersedes adr-XXXX`.
|
||||
Новые ADR добавляет архитектор при принятии решения (см. `CLAUDE.md` → Конвенции).
|
||||
23
docs/architecture/adr/adr-0001-multi-repo-registry.md
Normal file
23
docs/architecture/adr/adr-0001-multi-repo-registry.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# adr-0001: Реестр проектов (multi-repo)
|
||||
|
||||
- **Статус:** accepted
|
||||
- **Дата:** 2026-06-02
|
||||
- **Задача:** ORCH-6
|
||||
|
||||
## Контекст
|
||||
Инцидент 2026-06-02: Plane-вебхук слушал весь воркспейс и хардкодил `repo = settings.default_repo` (enduro-trails). Задачи ЛЮБОГО проекта сливались в один репо с одним префиксом (ET). Нужна изоляция по проектам.
|
||||
|
||||
## Решение
|
||||
Введён реестр `src/projects.py`: `ProjectConfig` (frozen dataclass) связывает `plane_project_id` → `repo` + `work_item_prefix` + `name`. Источник правды — env `ORCH_PROJECTS_JSON`; при пустом/невалидном — встроенный дефолт (`enduro-trails`/ET, `orchestrator`/ORCH). Позволяет: фильтровать вебхуки по проекту (неизвестный → ignore), резолвить gitea-репо + префикс, роутить Plane-синк в свой проект задачи.
|
||||
|
||||
## Альтернативы
|
||||
- Один репо на всё — отклонён (источник инцидента).
|
||||
- Хардкод маппинга в коде — отклонён в пользу env-конфигурируемого реестра с безопасным дефолтом.
|
||||
|
||||
## Последствия
|
||||
- Изоляция проектов на уровне вебхуков и роутинга.
|
||||
- Парсер устойчив: битый элемент скипается, пустой результат → дефолт.
|
||||
- Основа для `is_self_hosting_repo` (adr-0003).
|
||||
|
||||
## Связи
|
||||
adr-0003 (условный гейт опирается на repo из реестра).
|
||||
23
docs/architecture/adr/adr-0002-job-queue.md
Normal file
23
docs/architecture/adr/adr-0002-job-queue.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# adr-0002: Очередь задач вместо in-process потоков
|
||||
|
||||
- **Статус:** accepted
|
||||
- **Дата:** 2026-06-03
|
||||
- **Задача:** ORCH-1 (F-2b)
|
||||
|
||||
## Контекст
|
||||
Ранняя версия запускала стадии конвейера в in-process daemon-потоках. Проблемы: не переживало рестарт (задачи терялись), нет контроля параллелизма, нет ретраев, нет наблюдаемости.
|
||||
|
||||
## Решение
|
||||
Введена персистентная очередь задач (`src/queue_worker.py` + таблица `jobs` в SQLite): atomic claim задачи воркером, `max_concurrency`, ретраи при сбое, restart-safe (running-задачи реквестятся при старте), эндпоинт `GET /queue`.
|
||||
|
||||
## Альтернативы
|
||||
- In-process потоки — отклонены (не restart-safe).
|
||||
- Внешний брокер (Redis/RabbitMQ) — избыточно для текущего масштаба; SQLite-очередь проще и без новых зависимостей.
|
||||
|
||||
## Последствия
|
||||
- Конвейер переживает рестарт контейнера.
|
||||
- Контроль параллелизма и наблюдаемость через `/queue`.
|
||||
- ⚠️ Очередь общая на все проекты прод-инстанса — фактор группового риска при self-hosting (см. `docs/operations/INFRA.md`).
|
||||
|
||||
## Связи
|
||||
adr-0001 (реестр проектов), INFRA.md (общая очередь при self-hosting).
|
||||
27
docs/architecture/adr/adr-0003-staging-gate.md
Normal file
27
docs/architecture/adr/adr-0003-staging-gate.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# adr-0003: Условный staging-гейт перед прод-деплоем
|
||||
|
||||
- **Статус:** accepted
|
||||
- **Дата:** 2026-06-05
|
||||
- **Задача:** ORCH-35
|
||||
|
||||
## Контекст
|
||||
Оркестратор дорабатывает сам себя (self-hosting). Раньше стадия `deploy` имела «бумажный» вердикт: deployer-агент писал `deploy_status: SUCCESS`, но реального прогона на изолированной среде не было. Нужен предохранитель: прод-деплой орка не должен происходить, пока изменения не проверены на живой staging-среде. При этом другие проекты (enduro-trails) staging-инфры не имеют.
|
||||
|
||||
## Решение
|
||||
Добавлена промежуточная стадия `deploy-staging` между `testing` и `deploy`: `testing → deploy-staging → deploy → done`.
|
||||
- deployer гоняет `scripts/staging_check.py --base-url http://localhost:8501` и пишет `staging_status: SUCCESS|FAILED` в `15-staging-log.md`.
|
||||
- Quality Gate `check_staging_status` парсит вердикт (только YAML-frontmatter).
|
||||
- **Гейт условный:** `is_self_hosting_repo(repo)` → реальная проверка только для `orchestrator`; для остальных проектов гейт = no-op `(True, "Staging gate N/A")`.
|
||||
- FAILED → откат на `development`.
|
||||
|
||||
## Альтернативы
|
||||
- Глобальный гейт для всех проектов — отклонён: у enduro нет staging-инстанса, задачи застревали бы на откате.
|
||||
- Деплой реально дёргает хост-хук прямо здесь — отложен в ORCH-36 (Вариант B).
|
||||
|
||||
## Последствия
|
||||
- Прод-деплой орка недостижим, пока staging-гейт не зелёный.
|
||||
- Другие проекты не затронуты (no-op).
|
||||
- Реальный docker-деплой через хук пока НЕ выполняется (вердикт «бумажный», но подкреплён прогоном сьюта). Исполняемый деплой — ORCH-36.
|
||||
|
||||
## Связи
|
||||
adr-0001 (реестр проектов — основа `is_self_hosting_repo`), ORCH-34 (deploy-hook + rollback), ORCH-36 (исполняемый самодеплой).
|
||||
@@ -58,7 +58,8 @@ STAGE_TRANSITIONS = {
|
||||
architecture: → development (agent: developer, QG: check_architecture_done)
|
||||
development: → review (agent: reviewer, QG: check_tests_local)
|
||||
review: → testing (agent: tester, QG: check_reviewer_verdict)
|
||||
testing: → deploy (agent: deployer, QG: check_tests_passed)
|
||||
testing: → deploy-staging (agent: deployer, QG: check_tests_passed)
|
||||
deploy-staging: → deploy (agent: deployer, QG: check_staging_status)
|
||||
deploy: → done (agent: None, QG: None)
|
||||
}
|
||||
```
|
||||
@@ -189,8 +190,10 @@ services:
|
||||
12. Gitea PR webhook: review event → QG check_review_approved → PASS
|
||||
13. Advance: review → testing, tester launched
|
||||
14. Tester: прогоняет тесты, пишет test-report.md → git push
|
||||
15. Auto-advance: testing → deploy (QG check_tests_passed → PASS)
|
||||
16. PR merge → Gitea PR webhook: action=closed, merged=true → done
|
||||
15. Auto-advance: testing → deploy-staging (QG check_tests_passed → PASS)
|
||||
16. Deployer: runs staging checks → writes 15-staging-log.md (staging_status: SUCCESS)
|
||||
17. Auto-advance: deploy-staging → deploy (QG check_staging_status → PASS)
|
||||
18. PR merge → Gitea PR webhook: action=closed, merged=true → done
|
||||
```
|
||||
|
||||
### Review bounce path
|
||||
@@ -323,6 +326,10 @@ jobs со статусом `running` (воркер умёр на рестарт
|
||||
|
||||
- `ORCH_MAX_CONCURRENCY` (default 1) — лимит параллельных jobs.
|
||||
- `ORCH_QUEUE_POLL_INTERVAL` (default 2.0) — период опроса.
|
||||
- `ORCH_AGENT_MODEL_DEFAULT` / `ORCH_AGENT_MODEL_<AGENT>` (ORCH-41) — модель агентов; дефолт `claude-opus-4-8`.
|
||||
- `ORCH_AGENT_EFFORT_DEFAULT` / `ORCH_AGENT_EFFORT_<AGENT>` (ORCH-41) — режим `--effort` (low|medium|high|xhigh|max).
|
||||
- `ORCH_AGENT_FALLBACK_MODEL` (ORCH-41) — опц. `--fallback-model` при overloaded.
|
||||
- per-project override: `agent_models` / `agent_efforts` в `ORCH_PROJECTS_JSON`; резолверы `resolve_agent_model` / `resolve_agent_effort` (project > per-agent env > default > пусто).
|
||||
|
||||
Наблюдаемость: `GET /queue` — counts по статусам + последние 10 jobs.
|
||||
|
||||
121
docs/operations/INFRA.md
Normal file
121
docs/operations/INFRA.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# INFRA.md — инфраструктура и эксплуатация оркестратора
|
||||
|
||||
> RUNBOOK. Топология, контейнеры, порты, переменные окружения, границы.
|
||||
> **Секреты тут НЕ хранятся** — только дескрипторы. Реальные значения — в `.env` на хосте.
|
||||
|
||||
## Топология
|
||||
|
||||
```
|
||||
host: mva154 (slin@82.22.50.71), network_mode: host
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ orchestrator (PROD) :8500 env_file .env │
|
||||
│ БД: ./data/orchestrator.db (обслуживает ВСЕ прод-проекты) │
|
||||
│ │
|
||||
│ orchestrator-staging (STAGING) :8501 env_file .env.staging │
|
||||
│ БД: ./data/staging/orchestrator.db (изолирована, только sandbox) │
|
||||
│ profile: staging — НЕ стартует обычным `docker compose up` │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
│ webhooks │ git
|
||||
▼ ▼
|
||||
Plane (ag_proj) Gitea (localhost:3000)
|
||||
/repos/<project> ← общий каталог репозиториев (host: /home/slin/repos)
|
||||
```
|
||||
|
||||
## Контейнеры
|
||||
|
||||
| Контейнер | Роль | Порт | env_file | БД (хост) | Старт |
|
||||
|-----------|------|------|----------|-----------|-------|
|
||||
| `orchestrator` | прод | 8500 | `.env` | `./data/orchestrator.db` | `docker compose up -d` |
|
||||
| `orchestrator-staging` | staging / песочница | 8501 | `.env.staging` | `./data/staging/orchestrator.db` | `docker compose --profile staging up -d orchestrator-staging` |
|
||||
|
||||
Оба: `network_mode: host`, `init: true` (tini как PID 1 — reaping зомби, B-2), `restart: unless-stopped`.
|
||||
|
||||
### Тома (volumes)
|
||||
- `./data` → `/app/data` (БД; у staging — `./data/staging`)
|
||||
- `/home/slin/repos` → `/repos` (рабочие репозитории проектов)
|
||||
- `/var/run/docker.sock` (для docker-операций деплоя)
|
||||
- claude-code, node, `~/.claude*` (CLI агентов, ro)
|
||||
- `~/.orchestrator-ssh` → `/root/.ssh` (ro, деплой по ssh)
|
||||
|
||||
## Переменные окружения (карта; значения — в `.env`)
|
||||
|
||||
| Переменная | Назначение |
|
||||
|-----------|-----------|
|
||||
| `ORCH_PLANE_API_URL` / `_TOKEN` / `_WORKSPACE_SLUG` | доступ к Plane API |
|
||||
| `ORCH_PLANE_WEBHOOK_SECRET` | HMAC-проверка вебхуков Plane |
|
||||
| `ORCH_GITEA_URL` / `_TOKEN` / `_WEBHOOK_SECRET` | доступ к Gitea + HMAC |
|
||||
| `ORCH_CLAUDE_BIN` | путь к claude CLI |
|
||||
| `ORCH_REPOS_DIR` / `ORCH_HOST_REPOS_DIR` | каталог репозиториев (в контейнере / на хосте) |
|
||||
| `ORCH_DB_PATH` | путь к SQLite БД |
|
||||
| `ORCH_PROJECTS_JSON` | реестр проектов (Plane id → repo + prefix); пусто → дефолт из `src/projects.py` |
|
||||
| `ORCH_AGENT_MODEL_DEFAULT` | LLM-модель агентов по умолчанию (ORCH-41); дефолт `claude-opus-4-8` |
|
||||
| `ORCH_AGENT_MODEL_<AGENT>` | per-agent модель (ANALYST/ARCHITECT/DEVELOPER/REVIEWER/TESTER/DEPLOYER); пусто → default |
|
||||
| `ORCH_AGENT_EFFORT_DEFAULT` | режим работы `--effort` по умолчанию (ORCH-41): low\|medium\|high\|xhigh\|max; дефолт `high` |
|
||||
| `ORCH_AGENT_EFFORT_<AGENT>` | per-agent effort; дефолт: думающие → high, tester/deployer → medium |
|
||||
| `ORCH_AGENT_FALLBACK_MODEL` | опц. фолбэк-модель при overloaded (`--fallback-model`); пусто → без флага |
|
||||
| `DEPLOY_SSH_USER` / `_HOST` / `DEPLOY_HOOK_SCRIPT` | параметры деплой-хука |
|
||||
|
||||
**Секреты — только в `.env` / `.env.staging` на хосте, в гит НЕ коммитятся.** Канон — `.env.example`, `.env.staging.example`.
|
||||
|
||||
## Реестр проектов (`src/projects.py`, ORCH-6)
|
||||
Связывает Plane project id → gitea repo + work-item prefix. Источник: `ORCH_PROJECTS_JSON`, fallback — встроенный дефолт. Прод видит: `enduro-trails` (ET), `orchestrator` (ORCH). Staging видит ТОЛЬКО `orchestrator-sandbox` (SANDBOX) — изоляция.
|
||||
|
||||
## Модель и effort агентов (`src/config.py` + `src/agents/launcher.py`, ORCH-41)
|
||||
Модель LLM и режим работы (`--effort`) каждого агента **конфигурируемы** — глобально per-agent (env) и per-project (через `ORCH_PROJECTS_JSON`).
|
||||
|
||||
**Приоритет резолвинга** (`resolve_agent_model` / `resolve_agent_effort`):
|
||||
1. per-project override — `agent_models` / `agent_efforts` в записи `ORCH_PROJECTS_JSON`;
|
||||
2. per-agent env — `ORCH_AGENT_MODEL_<AGENT>` / `ORCH_AGENT_EFFORT_<AGENT>` (если непусто);
|
||||
3. глобальный дефолт — `ORCH_AGENT_MODEL_DEFAULT` (`claude-opus-4-8`) / `ORCH_AGENT_EFFORT_DEFAULT` (`high`);
|
||||
4. пусто → флаг не передаётся, действует дефолт CLI.
|
||||
|
||||
**Значения effort:** `low` < `medium` < `high` < `xhigh` < `max` — рычаг «качество vs стоимость/время». Дефолтная раскладка: думающие агенты (analyst/architect/developer/reviewer) → `high`, механические (tester/deployer) → `medium`. Невалидное значение → лог-warning, флаг опускается.
|
||||
|
||||
**Per-project override в `ORCH_PROJECTS_JSON`** (поля `agent_models` / `agent_efforts` опциональны, старые записи работают):
|
||||
```json
|
||||
{"plane_project_id":"...","repo":"orchestrator","work_item_prefix":"ORCH",
|
||||
"agent_models":{"developer":"claude-opus-4-8","reviewer":"claude-sonnet-4-6"},
|
||||
"agent_efforts":{"developer":"xhigh","tester":"low"}}
|
||||
```
|
||||
|
||||
> ⚠️ Бюджет (ORCH-38): `claude-opus-4-8` дефолт в коде; реальное переключение прод-env делается отдельно после согласования.
|
||||
|
||||
## ⚠️ Self-hosting — оркестратор дорабатывает САМ СЕБЯ
|
||||
|
||||
**Факт:** прод-инстанс `orchestrator` (8500) — ОДИН на ВСЕ прод-проекты (enduro-trails + orchestrator), с ОБЩЕЙ БД `./data/orchestrator.db` и общей очередью задач (ORCH-1).
|
||||
|
||||
**Следствие — групповой риск:** когда орк выполняет задачу из проекта ORCH (дорабатывает себя), он бежит в том же инстансе, что обслуживает enduro-trails.
|
||||
- Рестарт / падение прод-контейнера орк-задачей → конвейер ВСЕХ проектов встаёт.
|
||||
- Кривой self-деплой (ORCH-36, Вариант B) → лежат все проекты сразу.
|
||||
- Общая очередь → орк-задача занимает concurrency-слоты других проектов.
|
||||
|
||||
**Что изолировано (безопасно):**
|
||||
- Staging (8501) — отдельная БД (`./data/staging`), отдельный реестр (`ORCH_PROJECTS_JSON` = только sandbox). Прод-проекты не видит.
|
||||
- Репозитории разделены, изоляция веток через git worktree (ORCH-2).
|
||||
|
||||
**Страховки:**
|
||||
- Стадия `deploy-staging` (порт 8501) — обязательный гейт перед прод-деплоем орка. Прод-деплой недостижим, пока staging-гейт не зелёный (см. `STAGING.md`, ORCH-35). Гейт условный: реален только для self-hosting (repo=orchestrator), для остальных проектов — no-op.
|
||||
|
||||
**Правила для агентов при задачах ORCH:**
|
||||
1. НЕ перезапускать / не ронять прод-контейнер `orchestrator` в рамках задачи.
|
||||
2. Все проверки деплоя — на staging (8501), боевой 8500 не трогать.
|
||||
3. Деплой self — только через хук с health-check + авто-rollback (`DEPLOY_HOOK.md`).
|
||||
|
||||
## Эксплуатация (быстрые команды)
|
||||
```bash
|
||||
# статус
|
||||
docker ps --filter name=orchestrator
|
||||
curl -s http://localhost:8500/health
|
||||
curl -s http://localhost:8500/status # активные задачи
|
||||
curl -s http://localhost:8500/queue # очередь
|
||||
|
||||
# поднять staging-песочницу
|
||||
docker compose --profile staging up -d orchestrator-staging
|
||||
curl -s http://localhost:8501/health
|
||||
|
||||
# логи
|
||||
docker logs --tail 100 orchestrator
|
||||
```
|
||||
|
||||
---
|
||||
*RUNBOOK 2026-06-05. Обновлять при изменении топологии/портов/переменных. См. CONTRIBUTING.md §8.*
|
||||
7
docs/work-items/ORCH-016/00-business-request.md
Normal file
7
docs/work-items/ORCH-016/00-business-request.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Business Request: Единообразные коммент-артефакты в Plane от всех агентов
|
||||
|
||||
Work Item ID: ORCH-016
|
||||
|
||||
## Description
|
||||
|
||||
TBD
|
||||
85
docs/work-items/ORCH-016/01-brd.md
Normal file
85
docs/work-items/ORCH-016/01-brd.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# BRD: Единообразные коммент-артефакты в Plane от всех агентов
|
||||
|
||||
Work Item ID: **ORCH-016**
|
||||
Стадия: analysis
|
||||
Автор: analyst
|
||||
Дата: 2026-06-05
|
||||
Ревизия: 2 (учтён фидбэк стейкхолдера от 2026-06-05 — добавить длительность работы агента в коммент)
|
||||
|
||||
---
|
||||
|
||||
## 1. Бизнес-цель
|
||||
Стейкхолдер (Слава) должен мочь из ленты комментариев задачи в Plane **за один клик** перейти к артефакту любого агента (ADR, PR, ревью, отчёт тестера, деплой-лог), а не разбирать «шумные» строки без удобной ссылки и человекочитаемого описания.
|
||||
Помимо ссылок, по комментариям стейкхолдер хочет **видеть, сколько работал каждый агент** (длительность стадии), не открывая БД оркестратора и не лезя в `agent_runs`.
|
||||
|
||||
## 2. Мотивация
|
||||
Сейчас в Plane комменты двух разных стилей:
|
||||
|
||||
| Кто пишет | Формат коммента | Источник |
|
||||
|-----------|-----------------|----------|
|
||||
| **Аналитик (эталон)** | HTML: человеческое описание стадии + `<ul>` со списком ссылок на артефакты, заголовок «Документы:» | `src/stage_engine.py::_build_analyst_ready_comment` (PR #13) |
|
||||
| Architect / Developer / Reviewer / Tester / Deployer | Однострочник «{icon} Role готов · 8.5M in / 45.8k out · $7.29» + markdown-ссылки следом | `src/usage.py::usage_comment` + `artifact_links` |
|
||||
|
||||
Проблемы второго формата:
|
||||
1. Нет человеческого описания результата стадии — есть только техническая метрика «tokens/cost».
|
||||
2. Нет краткого вердикта одной строкой там, где он есть в артефакте (Reviewer `APPROVE/REQUEST_CHANGES`, Tester `PASS/FAIL`, Deployer `SUCCESS/FAILED`).
|
||||
3. Формат разнится по агентам (где-то «📂 Branch + 🔗 PR», где-то «📄 Test report») — нет единого визуального якоря.
|
||||
4. **Не видно длительности стадии** — стейкхолдер не понимает, агент отработал за 30 секунд или за 12 минут; это важная метрика для оценки SLA, поведения долгих стадий (testing/deploy) и подозрений на «зависание».
|
||||
|
||||
## 3. Целевая аудитория
|
||||
- **Стейкхолдер задачи (Слава, владелец продукта)** — главный потребитель ленты комментариев в Plane.
|
||||
- **Reviewer / QA / DevOps по другим проектам (enduro-trails)** — те же ссылки помогут им навигироваться по задачам, не открывая БД оркестратора.
|
||||
|
||||
## 4. Scope (что входит)
|
||||
1. Привести коммент-формат **architect, developer, reviewer, tester, deployer** к единому виду по эталону аналитика:
|
||||
- заголовок-роль (emoji + имя роли),
|
||||
- короткое человеческое описание результата стадии (1 предложение),
|
||||
- кликабельная ссылка(и) на СВОЙ артефакт,
|
||||
- **одна строка-вердикт** там, где это уместно (Reviewer / Tester / Deployer),
|
||||
- **одна строка-длительность** работы агента — для всех ролей, включая аналитика.
|
||||
2. Переиспользовать `settings.gitea_public_url` для кликабельных ссылок (готово в PR #14).
|
||||
3. Сохранить существующее поведение аналитика (PR #13) — он уже соответствует целевому формату; в идеале — переиспользовать общий хелпер. К аналитику также добавляется строка длительности.
|
||||
4. Один коммент на агента за прохождение стадии (без спама).
|
||||
5. Источник длительности — уже существующая метрика `_duration_s` в `src/agents/launcher.py` (или `agent_runs.started_at` / `finished_at`). Новых таблиц/полей в БД не заводим.
|
||||
|
||||
## 5. Out of scope (что НЕ трогаем)
|
||||
- Логика Quality Gates (`src/qg/checks.py`).
|
||||
- Status-only verdict model (PR #12) — приёмка аналитика через смену статуса Plane на «Approved/Rejected».
|
||||
- Дедупликация вебхуков (`src/webhooks/_dedup.py`).
|
||||
- `set_issue_done`, `notify_done`, `notify_qg_failure` — внутренние нотификации остаются как есть.
|
||||
- Per-agent bot-авторство (PR с `PLANE_BOT_TOKENS`) — сохраняется.
|
||||
- Изменение схемы БД, конвейера стадий, реестра QG.
|
||||
|
||||
## 6. Бизнес-требования
|
||||
**BR-1.** Каждый агент по завершении своей стадии (вне пути ошибки) пишет в Plane **ровно один** коммент в едином формате.
|
||||
**BR-2.** Коммент содержит:
|
||||
- заголовок с emoji-иконкой роли и человекочитаемым названием,
|
||||
- 1–2 предложения с описанием результата стадии на русском языке,
|
||||
- кликабельную ссылку (-и) на артефакт(ы) этого агента в Gitea,
|
||||
- одну строку вердикта (Verdict / Status), если артефакт его содержит,
|
||||
- **одну строку длительности работы агента** (`Длительность: <human-format>`), всегда, если значение известно.
|
||||
**BR-3.** Ссылки строятся через `gitea_public_url` (fallback на `gitea_url`).
|
||||
**BR-4.** Формат должен быть устойчив: отсутствующий артефакт / отсутствующий вердикт / неизвестная длительность не ломают коммент — соответствующая строка просто опускается.
|
||||
**BR-5.** Изменение **не нарушает**:
|
||||
- status-only verdict model (аналитик по-прежнему ждёт смены статуса Plane),
|
||||
- дедуп комментов и вебхуков,
|
||||
- работу `set_issue_done` / `notify_done` на финале конвейера,
|
||||
- per-agent bot-авторство.
|
||||
**BR-6.** Длительность отображается в человекочитаемой форме (`12s`, `4m 12s`, `1h 03m`), а не в виде голых секунд. Источник — `agent_runs.started_at` / `finished_at` (или уже посчитанный `_duration_s` в `launcher.py`). Новых полей в БД не вводится.
|
||||
|
||||
## 7. Ограничения и риски
|
||||
- **Self-hosting:** оркестратор правит сам себя; деплой только через staging-гейт (порт 8501) → прод-контейнер `orchestrator` не перезапускать в рамках задачи.
|
||||
- Прод обслуживает другие проекты (enduro-trails) — нельзя сломать комменты в их задачах.
|
||||
- Plane Bot-авторство (`_headers_for`) должно остаться — коммент пишется под бот-токеном своей роли.
|
||||
- Reviewer/tester вердикты читаются из артефактов; нужно идемпотентно работать, если артефакт ещё не закоммичен / не доступен в worktree.
|
||||
|
||||
## 8. Связки
|
||||
- PR #13 — `status-only analyst comment with doc links` (эталон формата аналитика).
|
||||
- PR #14 — `external gitea_public_url for clickable doc links` (источник кликабельных ссылок).
|
||||
- ADR не требуется: сквозной архитектурный сдвиг отсутствует, меняем только формирование текста коммента в существующем потоке.
|
||||
|
||||
## 9. Критерии успеха (high-level)
|
||||
- Слава открывает любую задачу в Plane и в ленте видит однотипные карточки от каждого агента: «{role} — {описание} → ссылка [Verdict: …] [Длительность: …]».
|
||||
- По любой ссылке открывается соответствующий документ в Gitea (HTTP 200, корректный путь).
|
||||
- В каждом статус-комменте присутствует строка «Длительность: …» с человекочитаемым значением (`12s` / `4m 12s` / `1h 03m`).
|
||||
- Никаких регрессий в существующих тестах `tests/`.
|
||||
174
docs/work-items/ORCH-016/02-trz.md
Normal file
174
docs/work-items/ORCH-016/02-trz.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# ТЗ: Единообразные коммент-артефакты в Plane от всех агентов
|
||||
|
||||
Work Item ID: **ORCH-016**
|
||||
Стадия: analysis → architecture → development
|
||||
Автор: analyst
|
||||
Дата: 2026-06-05
|
||||
Ревизия: 2 (по фидбэку стейкхолдера — добавлен §2.5 Duration; обновлены §1, §2.1, §6)
|
||||
|
||||
> Контракт: что именно меняем в коде / какие модули задействованы / какие проверки появятся.
|
||||
> Архитектурные решения принимает архитектор; здесь — границы изменения.
|
||||
|
||||
---
|
||||
|
||||
## 1. Задействованные модули
|
||||
|
||||
| Модуль | Роль в изменении |
|
||||
|--------|------------------|
|
||||
| `src/usage.py` | **Главная точка изменения.** Здесь сейчас живут `usage_comment()`, `artifact_links()`, `AGENT_ARTIFACT`, `AGENT_DISPLAY`, `AGENT_ICON` — основа форматирования. Нужно расширить/добавить хелпер построения единого status-коммента + утилитку форматирования длительности (`fmt_duration(seconds: int) -> str`). |
|
||||
| `src/stage_engine.py` | Эталонная функция аналитика `_build_analyst_ready_comment()`. По возможности — переиспользовать новый общий хелпер (или хотя бы выровнять формат: emoji + заголовок + описание + список ссылок). К аналитику также прикручиваем строку длительности (см. §2.5). |
|
||||
| `src/agents/launcher.py` | `_post_usage_comments()` — точка, где постится коммент по завершении агента (architect/developer/reviewer/tester/deployer). Должен звать новый хелпер. `_duration_s` уже считается на строке `391` — пробросить его (или достать из `agent_runs.started_at`/`finished_at`) в хелпер. |
|
||||
| `src/db.py` | **Только для чтения** в рантайме коммент-хелпера: `agent_runs.started_at`, `agent_runs.finished_at` (уже существуют). Никаких ALTER. |
|
||||
| `src/plane_sync.py` | `add_comment()` — без изменений (используется как транспорт). |
|
||||
| `src/qg/checks.py` | **Только для чтения**: модели парсинга frontmatter `verdict:` / `deploy_status:` / `staging_status:` — переиспользуем эту логику (вынести в отдельную утилитку, либо импортировать там, где она уже есть). |
|
||||
| `src/config.py` | `settings.gitea_public_url`, `settings.gitea_owner`, `settings.gitea_url` — без изменений, переиспользуются. |
|
||||
|
||||
## 2. Контракт нового коммент-формата
|
||||
|
||||
### 2.1 Структура (одинакова для всех агентов)
|
||||
```
|
||||
{ICON} {RoleName} — {one-line human description of stage result}
|
||||
|
||||
[Verdict / Status: <VALUE>] # опционально, см. 2.3
|
||||
Длительность: <human-format> # см. 2.5; опускается, только если значение неизвестно
|
||||
<b>Документы:</b>
|
||||
• <a href="…">{label}</a> # одна или несколько ссылок
|
||||
```
|
||||
|
||||
Поля:
|
||||
- `{ICON}` — берётся из `AGENT_ICON` (уже есть в `usage.py`).
|
||||
- `{RoleName}` — из `AGENT_DISPLAY` (уже есть).
|
||||
- `{description}` — фиксированная строка на роль, см. 2.2.
|
||||
- Verdict / Status — см. 2.3, опускается если не извлекается.
|
||||
- Длительность — см. 2.5, печатается всегда, когда значение есть; по умолчанию доступна (это нативная метрика `agent_runs`).
|
||||
- Ссылки — см. 2.4.
|
||||
|
||||
### 2.2 Описания стадий (per-agent text)
|
||||
|
||||
| Агент | Описание (рус.) |
|
||||
|-------|------------------|
|
||||
| analyst | «Подготовил BRD / ТЗ / Acceptance Criteria. Для продвижения переведите задачу в статус Approved.» (как сейчас в `_build_analyst_ready_comment`) |
|
||||
| architect | «Завершил архитектурную проработку. См. ADR ниже.» |
|
||||
| developer | «Завершил разработку. См. PR / branch ниже.» |
|
||||
| reviewer | «Завершил ревью изменений.» |
|
||||
| tester | «Завершил прогон тестов.» |
|
||||
| deployer | «Завершил деплой.» |
|
||||
|
||||
Точные формулировки финализирует architect; аналитик фиксирует **факт** наличия 1-предложного описания на каждую роль.
|
||||
|
||||
### 2.3 Verdict / Status строка
|
||||
|
||||
Печатается отдельной строкой над списком документов. Источник — frontmatter артефакта; парсить идемпотентно (если файл недоступен — строку пропустить):
|
||||
|
||||
| Агент | Поле | Где парсим | Возможные значения | Формат строки |
|
||||
|-------|------|------------|---------------------|----------------|
|
||||
| analyst | — | — | — | не печатается |
|
||||
| architect | — | — | — | не печатается |
|
||||
| developer | — | — | — | не печатается (CI-статус — отдельный гейт) |
|
||||
| reviewer | `verdict:` | `docs/work-items/<wid>/12-review.md` (YAML-frontmatter) | `APPROVE` / `REQUEST_CHANGES` | `Verdict: APPROVE` |
|
||||
| tester | `verdict:` (или эквивалентный фронт-кей) | `docs/work-items/<wid>/13-test-report.md` | `PASS` / `FAIL` | `Verdict: PASS` |
|
||||
| deployer | `staging_status:` (для deploy-staging) / `deploy_status:` (для deploy) | `15-staging-log.md` / `14-deploy-log.md` | `SUCCESS` / `FAILED` | `Status: SUCCESS` |
|
||||
|
||||
Если значение в frontmatter отсутствует или не распознано → строка `Verdict / Status` НЕ выводится (вердикт-парсинг гейтов и сама логика гейтов не меняется).
|
||||
|
||||
### 2.4 Ссылки на артефакты
|
||||
|
||||
Базовый URL: `(settings.gitea_public_url or settings.gitea_url).rstrip('/')`.
|
||||
Префикс: `/{owner}/{repo}/src/branch/{branch}/`.
|
||||
|
||||
| Агент | Артефакты (label → путь) |
|
||||
|-------|----------------------------|
|
||||
| analyst | BRD `01-brd.md`, ТЗ `02-trz.md`, AC `03-acceptance-criteria.md`, Test Plan `04-test-plan.yaml` *(уже есть)* |
|
||||
| architect | ADR-папка `docs/work-items/<wid>/06-adr/` *(уже есть)* |
|
||||
| developer | Branch `…/src/branch/<branch>`, PR `…/pulls/<num>` *(уже есть)* |
|
||||
| reviewer | Review `docs/work-items/<wid>/12-review.md` *(уже есть)* |
|
||||
| tester | Test report `docs/work-items/<wid>/13-test-report.md` *(уже есть)* |
|
||||
| deployer | Deploy log `docs/work-items/<wid>/14-deploy-log.md`; staging-лог `15-staging-log.md` (если применимо к стадии) |
|
||||
|
||||
Несуществующий файл в worktree → ссылка опускается (как сейчас в `_build_analyst_ready_comment`).
|
||||
|
||||
### 2.5 Строка длительности работы агента
|
||||
|
||||
**Что печатаем:** одну строку вида `Длительность: {human}` (или `Duration: {human}` — финальную локализацию метки фиксирует архитектор; русский предпочтителен, остальные комменты уже на русском).
|
||||
|
||||
**Источник значения (приоритет сверху вниз):**
|
||||
|
||||
1. **Параметр функции** — `_post_usage_comments()` в `src/agents/launcher.py:682` вызывается из контекста, где `_duration_s` уже посчитан на строке `391` (`int(time.time() - _start_ts)`). Простейший путь — пробросить `duration_s` явным аргументом в `usage_comment(...)` / новый `build_status_comment(...)`.
|
||||
2. **Fallback из БД** — если параметр не передан (например, для аналитика, чей коммент строится в `_build_analyst_ready_comment` в `src/stage_engine.py:298`), читаем
|
||||
```sql
|
||||
SELECT
|
||||
CAST((julianday(finished_at) - julianday(started_at)) * 86400 AS INTEGER)
|
||||
FROM agent_runs
|
||||
WHERE task_id = ? AND agent = ?
|
||||
ORDER BY id DESC LIMIT 1
|
||||
```
|
||||
Это последний завершённый run этой роли по задаче.
|
||||
3. **Если оба источника пусты / `None` / отрицательны** — строка `Длительность:` НЕ печатается (graceful, как и для вердикта).
|
||||
|
||||
**Форматирование (`fmt_duration(seconds: int) -> str` в `src/usage.py`):**
|
||||
|
||||
| Диапазон | Формат | Пример |
|
||||
|----------|--------|--------|
|
||||
| `0 ≤ s < 60` | `{s}s` | `12s`, `45s` |
|
||||
| `60 ≤ s < 3600` | `{m}m {ss}s` | `4m 12s`, `1m 03s` |
|
||||
| `s ≥ 3600` | `{h}h {mm}m` (секунды отбрасываем) | `1h 03m`, `2h 47m` |
|
||||
|
||||
Округление: целые секунды (input — `int`). При `s == 0` всё равно печатаем `0s` (видно, что метрика известна и стадия отработала почти мгновенно).
|
||||
|
||||
**Покрытие ролей:** строка длительности добавляется для **всех** агентов, включая аналитика. Для аналитика — строго через fallback из `agent_runs` (его коммент строится в `stage_engine.py`, не в `launcher.py`).
|
||||
|
||||
**Что НЕ делаем:**
|
||||
- Не меняем схему `agent_runs` (поля `started_at` / `finished_at` уже есть, `_duration_s` уже считается).
|
||||
- Не изобретаем новый отдельный коммент с длительностью — длительность встраивается в существующий status-коммент.
|
||||
- Не считаем «время от первого вебхука до коммента» — берём чистое время процесса агента (тот же `_duration_s`, что попадает в `notify_agent_finished`), чтобы значение совпадало с тем, что уже видно в Telegram live tracker / логах.
|
||||
|
||||
### 2.6 Один коммент на агента за стадию
|
||||
Текущий триггер — `_post_usage_comments()` вызывается **один раз** в успешном auto-advance пути после агента. Никаких новых триггеров не добавляем. Дубликаты исключены текущей логикой (одно завершение агента → один коммент).
|
||||
|
||||
### 2.7 Usage-метрики (токены / стоимость)
|
||||
Текущий `usage_comment()` встраивает «8.5M in / 45.8k out · $7.29» в первый строкой. По требованиям Славы это «без раздувания», но не запрещено явно. Решение:
|
||||
- **Сохранить** usage-метрику как **последнюю строку** коммента (мелким техническим хвостом, например `<sub>8.5M in / 45.8k out · $7.29 · Длительность: 4m 12s</sub>`), либо
|
||||
- **Перенести** в `task_summary_comment` (только для финального deployer-summary).
|
||||
|
||||
Финальный выбор — за архитектором (см. вопрос Q-1 в `10-tech-risks.md`). Длительность из §2.5 — **отдельная** строка от usage-метрики и присутствует независимо от того, как решится вопрос про токены/стоимость.
|
||||
|
||||
### 2.8 Бот-авторство
|
||||
`plane_add_comment(..., author=<role>)` — сохраняется. Все агенты комментируют под своим bot-токеном (`PLANE_BOT_TOKENS`). Изменения формата текста на это не влияют.
|
||||
|
||||
## 3. Изменения API
|
||||
**Нет.** Внешние webhooks (`/webhook/plane`, `/webhook/gitea`), `/health`, `/status`, `/queue` — не меняются.
|
||||
|
||||
## 4. Изменения схемы БД
|
||||
**Нет.** Используются существующие таблицы `tasks`, `agent_runs`, `jobs`.
|
||||
|
||||
## 5. Новые Quality Gate checks
|
||||
**Нет.** Гейты не меняются. Парсинг `verdict:` / `deploy_status:` / `staging_status:` в коммент — отдельная утилитка, не QG.
|
||||
|
||||
## 6. Требования к коду
|
||||
- Все новые функции — с docstring (зачем нужны, какие инварианты сохраняют).
|
||||
- Парсинг frontmatter артефакта — graceful: исключение → строка вердикта опускается, лог в `logger.debug`.
|
||||
- Чтение длительности — graceful: исключение или `None` → строка длительности опускается, лог в `logger.debug`. Отрицательные / нулевые значения: `0` печатается как `0s`, отрицательные опускаются.
|
||||
- `fmt_duration(seconds: int) -> str` — чистая, без БД-зависимостей, легко тестируется юнитом.
|
||||
- Никаких новых внешних зависимостей: использовать `pyyaml` (уже в проекте) или существующий парсер frontmatter из `src/qg/checks.py`.
|
||||
- Поведение для проектов **без** артефактов (например, ENDURO-* до запуска агента) — graceful no-op: коммент с описанием и без ссылок (минимум — заголовок).
|
||||
- HTML (как у аналитика) предпочтительнее markdown — Plane корректно рендерит `<ul><li><a>` и `<b>`.
|
||||
|
||||
## 7. Артефакты по pipeline
|
||||
- `06-adr/` — **не требуется** (нет архитектурного сдвига; обсуждается локально архитектором, в случае спорного решения по 2.6 — заводим ADR `ADR-001-status-comment-format.md`).
|
||||
- `07-infra-requirements.md` — **не требуется** (нет новой инфраструктуры).
|
||||
- `08-data-requirements.md` — **не требуется** (БД не меняется).
|
||||
- `12-review.md` / `13-test-report.md` / `14-deploy-log.md` — формируются на соответствующих стадиях по канону.
|
||||
- `CHANGELOG.md` — обновить в том же PR (раздел `Unreleased`).
|
||||
|
||||
## 8. Документация
|
||||
В том же PR обновить:
|
||||
- `docs/architecture/README.md` — короткое упоминание единого формата комментов (можно в раздел «Plane Sync»).
|
||||
- `docs/architecture/internals.md` — если там есть раздел про `usage.py`/комменты — обновить.
|
||||
- `CLAUDE.md` — без изменений (правила не меняются).
|
||||
|
||||
## 9. Чего НЕ делать
|
||||
- Не менять реестр `QG_CHECKS`.
|
||||
- Не менять `STAGE_TRANSITIONS`.
|
||||
- Не менять `add_comment` / `_headers_for` / `PLANE_BOT_TOKENS`.
|
||||
- Не «комментировать» комменты других стадий задним числом.
|
||||
- Не использовать `--no-verify` при коммитах.
|
||||
125
docs/work-items/ORCH-016/03-acceptance-criteria.md
Normal file
125
docs/work-items/ORCH-016/03-acceptance-criteria.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# Acceptance Criteria: Единообразные коммент-артефакты в Plane
|
||||
|
||||
Work Item ID: **ORCH-016**
|
||||
Ревизия: 2 (по фидбэку стейкхолдера — все AC по агентам обновлены под строку длительности; добавлены AC-13 / AC-14)
|
||||
|
||||
Каждый AC сформулирован как чёткое условие PASS/FAIL. Проверяется автоматически (unit/integration) либо ручной верификацией в staging Plane (порт 8501).
|
||||
|
||||
---
|
||||
|
||||
## AC-1. Архитектор пишет единообразный коммент
|
||||
- **Given** task завершила стадию `architecture` успешно, `06-adr/` содержит как минимум один ADR.
|
||||
- **When** `_post_usage_comments(agent="architect", ...)` вызывается.
|
||||
- **Then** в Plane появляется **ровно один** коммент со структурой:
|
||||
- первая строка: `📐 Architect — Завершил архитектурную проработку. См. ADR ниже.`,
|
||||
- строка `Длительность: <human>` (формат — см. AC-13), значение соответствует фактическому времени работы архитектора (±1с),
|
||||
- блок «Документы:» с кликабельной ссылкой на `…/src/branch/<branch>/docs/work-items/<wid>/06-adr/`,
|
||||
- **нет** строки `Verdict / Status`.
|
||||
- **And** автор коммента — `architect` (`PLANE_BOT_TOKENS["architect"]`, fallback на shared token).
|
||||
- **PASS** при выполнении всех пунктов; **FAIL** при отсутствии любого.
|
||||
|
||||
## AC-2. Разработчик пишет единообразный коммент
|
||||
- **Given** task завершила стадию `development`, есть open PR.
|
||||
- **When** `_post_usage_comments(agent="developer", ...)` вызывается.
|
||||
- **Then** коммент в Plane:
|
||||
- `💻 Developer — Завершил разработку. См. PR / branch ниже.`,
|
||||
- строка `Длительность: <human>`,
|
||||
- ссылки: `Branch <branch>` → `…/src/branch/<branch>`, `PR #<num>` → `…/pulls/<num>`,
|
||||
- **нет** строки `Verdict`.
|
||||
|
||||
## AC-3. Ревьюер пишет коммент с вердиктом
|
||||
- **Given** `12-review.md` содержит frontmatter `verdict: APPROVE` (или `REQUEST_CHANGES`).
|
||||
- **When** `_post_usage_comments(agent="reviewer", ...)` вызывается.
|
||||
- **Then** коммент:
|
||||
- `🔎 Reviewer — Завершил ревью изменений.`,
|
||||
- строка `Verdict: APPROVE` (или `REQUEST_CHANGES`) — содержимое соответствует frontmatter,
|
||||
- строка `Длительность: <human>`,
|
||||
- ссылка `Review` → `…/12-review.md`.
|
||||
- **And** если frontmatter не содержит `verdict:` или файл недоступен — строка `Verdict:` опускается, остальное (в т.ч. длительность) публикуется.
|
||||
|
||||
## AC-4. Тестер пишет коммент с вердиктом
|
||||
- **Given** `13-test-report.md` содержит frontmatter `verdict: PASS` (или `FAIL`).
|
||||
- **When** `_post_usage_comments(agent="tester", ...)` вызывается.
|
||||
- **Then** коммент:
|
||||
- `🧪 Tester — Завершил прогон тестов.`,
|
||||
- строка `Verdict: PASS` (либо `FAIL`),
|
||||
- строка `Длительность: <human>`,
|
||||
- ссылка `Test report` → `…/13-test-report.md`.
|
||||
|
||||
## AC-5. Деплоер пишет коммент со статусом
|
||||
- **Given** task прошла стадию `deploy` (или `deploy-staging`), артефакт-лог существует с frontmatter `deploy_status: SUCCESS` (или `staging_status: SUCCESS`).
|
||||
- **When** `_post_usage_comments(agent="deployer", ...)` вызывается.
|
||||
- **Then** коммент:
|
||||
- `🚀 Deployer — Завершил деплой.`,
|
||||
- строка `Status: SUCCESS` (или `FAILED`),
|
||||
- строка `Длительность: <human>`,
|
||||
- ссылка `Deploy log` → `…/14-deploy-log.md` (и/или `Staging log` → `…/15-staging-log.md` для staging-стадии).
|
||||
|
||||
## AC-6. Аналитик не регрессирует
|
||||
- **Given** существующий поток PR #12/#13 (status-only verdict).
|
||||
- **When** аналитик завершает стадию `analysis` с готовыми `01..04`.
|
||||
- **Then** в Plane:
|
||||
- issue переведён в `In Review` (не меняется),
|
||||
- коммент содержит **то же** человеческое описание (Approved/Rejected инструкции) и список ссылок `BRD / ТЗ / AC / Test Plan` — формат либо идентичен текущему, либо построен через тот же общий хелпер, что и остальные агенты, без потери смысла,
|
||||
- дополнительно к существующему содержимому в комменте присутствует строка `Длительность: <human>` — значение поднимается из `agent_runs` (последний завершённый run агента `analyst` для этой задачи).
|
||||
|
||||
## AC-7. Один коммент на агента за стадию
|
||||
- **Given** агент успешно отработал стадию.
|
||||
- **When** наблюдаем ленту Plane.
|
||||
- **Then** для **каждого** агента (`architect`, `developer`, `reviewer`, `tester`, `deployer`) на стадию приходится **ровно один** status-коммент с артефактами. Дополнительные сервисные комменты (`notify_stage_change`, `notify_qg_failure`, `notify_done`) сохраняются — они не считаются status-комментом.
|
||||
|
||||
## AC-8. Graceful fallback при отсутствии артефакта
|
||||
- **Given** артефакт (например, `12-review.md`) ОТСУТСТВУЕТ в worktree на момент коммента (нестандартный сценарий).
|
||||
- **When** `_post_usage_comments(agent="reviewer", ...)` вызывается.
|
||||
- **Then** коммент всё равно публикуется: заголовок + описание, без ссылки на отсутствующий артефакт и без строки `Verdict:`. Исключения не пробрасываются.
|
||||
|
||||
## AC-9. Кликабельность через gitea_public_url
|
||||
- **Given** в `.env` задан `GITEA_PUBLIC_URL=https://git.mva154.duckdns.org`, отличный от `GITEA_URL`.
|
||||
- **When** любой агент пишет status-коммент.
|
||||
- **Then** href всех артефакт-ссылок начинается с `https://git.mva154.duckdns.org/` (а не с внутреннего `gitea_url`).
|
||||
- **And** при отсутствии `gitea_public_url` (пустая строка) — fallback на `gitea_url` (обратная совместимость).
|
||||
|
||||
## AC-10. Существующие тесты зелёные
|
||||
- **Given** новый код влит в feature-ветку.
|
||||
- **When** запускается `pytest tests/ -q`.
|
||||
- **Then** все ранее существовавшие тесты проходят (нет регрессий status-only verdict, дедупа, `set_issue_done`).
|
||||
|
||||
## AC-11. Quality Gates не меняются
|
||||
- **Given** изменения формата комментов.
|
||||
- **When** инспектируется `src/qg/checks.py` и `src/stages.py`.
|
||||
- **Then** реестр `QG_CHECKS` и `STAGE_TRANSITIONS` остаются идентичными версии до PR (diff в этих файлах = ∅).
|
||||
|
||||
## AC-12. Документация обновлена
|
||||
- **Given** реализация добавлена в feature-ветку.
|
||||
- **When** reviewer проверяет PR.
|
||||
- **Then** в diff присутствуют обновления:
|
||||
- `CHANGELOG.md` (раздел Unreleased, описание изменения — включая «строку длительности агента в комментах»),
|
||||
- `docs/architecture/README.md` или `docs/architecture/internals.md` (упоминание единого формата status-комментов и строки длительности).
|
||||
- **And** при отсутствии обновлений документации reviewer ставит `verdict: REQUEST_CHANGES` (правило проекта).
|
||||
|
||||
## AC-13. Формат строки длительности
|
||||
- **Given** утилитка `fmt_duration(seconds: int) -> str` в `src/usage.py`.
|
||||
- **When** ей передаются граничные значения.
|
||||
- **Then** возвращаемая строка соответствует таблице:
|
||||
- `0` → `"0s"`
|
||||
- `12` → `"12s"`
|
||||
- `59` → `"59s"`
|
||||
- `60` → `"1m 00s"`
|
||||
- `252` → `"4m 12s"`
|
||||
- `3599` → `"59m 59s"`
|
||||
- `3600` → `"1h 00m"`
|
||||
- `3780` → `"1h 03m"`
|
||||
- `10020` → `"2h 47m"`
|
||||
- **And** ввод `None` или отрицательное значение → функция возвращает пустую строку (или `None`), а вызывающая сторона строку `Длительность:` не печатает.
|
||||
- **PASS** при полном совпадении со всеми примерами таблицы.
|
||||
|
||||
## AC-14. Длительность — graceful fallback
|
||||
- **Given** агент завершился, но `_duration_s` не пробрасывается явным параметром в коммент-хелпер (например, для аналитика).
|
||||
- **When** строится status-коммент.
|
||||
- **Then** хелпер запрашивает БД: последний `agent_runs` для `(task_id, agent)` с непустым `finished_at`, считает `int((julianday(finished_at) - julianday(started_at)) * 86400)` и подставляет в `fmt_duration`.
|
||||
- **And** при отсутствии подходящей строки `agent_runs` (или `finished_at IS NULL`, или результат < 0) — строка `Длительность:` опускается; остальные части коммента (заголовок, описание, вердикт, ссылки) публикуются без изменений.
|
||||
- **And** ошибка чтения БД не пробрасывает исключение наружу — логируется в `logger.debug` и трактуется как «значение неизвестно».
|
||||
|
||||
---
|
||||
|
||||
**Финальный PASS задачи:** все AC-1…AC-14 = PASS.
|
||||
154
docs/work-items/ORCH-016/04-test-plan.yaml
Normal file
154
docs/work-items/ORCH-016/04-test-plan.yaml
Normal file
@@ -0,0 +1,154 @@
|
||||
work_item: ORCH-016
|
||||
title: "Единообразные коммент-артефакты в Plane от всех агентов"
|
||||
revision: 2 # +TC-21..TC-25 по длительности (фидбэк стейкхолдера)
|
||||
tests:
|
||||
|
||||
- id: TC-01
|
||||
type: unit
|
||||
description: "build_status_comment(architect, duration_s=312, ...) формирует HTML c заголовком '📐 Architect — …', описанием стадии, строкой 'Длительность: 5m 12s' и ссылкой на 06-adr/. Строки Verdict нет."
|
||||
module: tests/test_status_comment_format.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-02
|
||||
type: unit
|
||||
description: "build_status_comment(developer, branch=..., pr_number=42, duration_s=...) включает ссылки на branch и на PR #42 через gitea_public_url + строку 'Длительность: ...'. Строки Verdict нет."
|
||||
module: tests/test_status_comment_format.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-03
|
||||
type: unit
|
||||
description: "build_status_comment(reviewer, duration_s=..., ...) при verdict=APPROVE в 12-review.md frontmatter выводит строку 'Verdict: APPROVE', строку 'Длительность: ...' и ссылку на 12-review.md."
|
||||
module: tests/test_status_comment_format.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-04
|
||||
type: unit
|
||||
description: "build_status_comment(reviewer, ...) при verdict=REQUEST_CHANGES выводит 'Verdict: REQUEST_CHANGES'. Строка длительности сохраняется."
|
||||
module: tests/test_status_comment_format.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-05
|
||||
type: unit
|
||||
description: "build_status_comment(reviewer, ...) при отсутствии файла 12-review.md публикует коммент без строки Verdict и без ссылки Review (graceful), при этом строка 'Длительность: ...' печатается, если duration_s передан."
|
||||
module: tests/test_status_comment_format.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-06
|
||||
type: unit
|
||||
description: "build_status_comment(tester, ...) при verdict=PASS в 13-test-report.md выводит 'Verdict: PASS', строку 'Длительность: ...' и ссылку на 13-test-report.md."
|
||||
module: tests/test_status_comment_format.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-07
|
||||
type: unit
|
||||
description: "build_status_comment(tester, ...) при verdict=FAIL выводит 'Verdict: FAIL'. Строка длительности сохраняется."
|
||||
module: tests/test_status_comment_format.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-08
|
||||
type: unit
|
||||
description: "build_status_comment(deployer, ...) при deploy_status=SUCCESS в 14-deploy-log.md выводит 'Status: SUCCESS', строку 'Длительность: ...' и ссылку на 14-deploy-log.md."
|
||||
module: tests/test_status_comment_format.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-09
|
||||
type: unit
|
||||
description: "build_status_comment(deployer, stage='deploy-staging') читает staging_status: из 15-staging-log.md и выводит соответствующую строку Status + строку длительности."
|
||||
module: tests/test_status_comment_format.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-10
|
||||
type: unit
|
||||
description: "URL ссылок строится через settings.gitea_public_url когда он задан; иначе — через settings.gitea_url (fallback)."
|
||||
module: tests/test_status_comment_format.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-11
|
||||
type: unit
|
||||
description: "Аналитик: _build_analyst_ready_comment (или его замена общим хелпером) сохраняет существующий контракт — текст про Approved/Rejected статус + список существующих BRD/ТЗ/AC/Test Plan ссылок. Дополнительно: при наличии завершённой строки agent_runs(analyst) для задачи коммент содержит строку 'Длительность: ...'."
|
||||
module: tests/test_analyst_comment_regression.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-12
|
||||
type: unit
|
||||
description: "Парсер frontmatter (verdict / deploy_status / staging_status) возвращает None при отсутствии файла, пустом файле или некорректном YAML — без проброса исключения."
|
||||
module: tests/test_status_comment_format.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-13
|
||||
type: integration
|
||||
description: "_post_usage_comments(agent='reviewer', ...) вызывает plane_sync.add_comment ровно один раз; передаваемый текст содержит '🔎 Reviewer', 'Verdict:', 'Длительность:' и href на 12-review.md."
|
||||
module: tests/test_post_usage_comments_integration.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-14
|
||||
type: integration
|
||||
description: "_post_usage_comments(agent='tester', ...) вызывает add_comment ровно один раз с автором 'tester' и корректным текстом, включая строку 'Длительность: ...'."
|
||||
module: tests/test_post_usage_comments_integration.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-15
|
||||
type: integration
|
||||
description: "_post_usage_comments(agent='deployer', ...) для стадии deploy постит коммент со ссылкой на 14-deploy-log.md, строкой 'Длительность: ...' И task_summary_comment (если оно сохраняется) — поведение не регрессирует."
|
||||
module: tests/test_post_usage_comments_integration.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-16
|
||||
type: integration
|
||||
description: "Регрессия status-only verdict model: при завершении analyst issue переводится в In Review, постится один коммент аналитика с инструкцией про статус Approved/Rejected, никакой автомат-advance не происходит."
|
||||
module: tests/test_analyst_status_only_regression.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-17
|
||||
type: integration
|
||||
description: "Регрессия дедупликации: повторный вебхук Plane с тем же event_id не приводит ко второму status-комменту от агента."
|
||||
module: tests/test_status_comment_dedup_regression.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-18
|
||||
type: integration
|
||||
description: "Регрессия set_issue_done / notify_done: финальный путь deploy→done по-прежнему переводит issue в Done и постит '✅ Task completed!' (отдельным комментом от status-коммента деплоера)."
|
||||
module: tests/test_notify_done_regression.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-19
|
||||
type: integration
|
||||
description: "Per-agent bot-авторство: status-комменты архитектора/разработчика/ревьюера/тестера/деплоера POST-ятся под соответствующим X-API-Key (PLANE_BOT_TOKENS[role]); fallback на PLANE_HEADERS при отсутствии бот-токена."
|
||||
module: tests/test_status_comment_authorship.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-20
|
||||
type: unit
|
||||
description: "Quality Gates не изменены: реестр QG_CHECKS и STAGE_TRANSITIONS идентичны контрольному снапшоту (smoke-тест против случайных правок)."
|
||||
module: tests/test_qg_registry_snapshot.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-21
|
||||
type: unit
|
||||
description: "fmt_duration(seconds) — табличная проверка форматирования: 0→'0s', 12→'12s', 59→'59s', 60→'1m 00s', 252→'4m 12s', 3599→'59m 59s', 3600→'1h 00m', 3780→'1h 03m', 10020→'2h 47m'."
|
||||
module: tests/test_fmt_duration.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-22
|
||||
type: unit
|
||||
description: "fmt_duration(None) и fmt_duration(-1) возвращают пустую строку (или None); вызывающая сторона при этом строку 'Длительность:' НЕ печатает."
|
||||
module: tests/test_fmt_duration.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-23
|
||||
type: unit
|
||||
description: "build_status_comment(architect, duration_s=None) и build_status_comment(architect) — коммент НЕ содержит строки 'Длительность:'; остальные строки (заголовок/описание/ссылки) на месте."
|
||||
module: tests/test_status_comment_format.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-24
|
||||
type: integration
|
||||
description: "Fallback по БД: при отсутствии явного duration_s билдер коммента читает agent_runs.started_at/finished_at для последней завершённой строки (task_id, agent) и подставляет fmt_duration результата. Проверка через тестовую SQLite с заранее проставленными timestamp'ами."
|
||||
module: tests/test_status_comment_duration_db_fallback.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-25
|
||||
type: integration
|
||||
description: "Регрессия: исключение при чтении agent_runs (например, БД залочена) → строка 'Длительность:' опускается, остальное публикуется; logger.debug содержит запись о неудачном чтении длительности."
|
||||
module: tests/test_status_comment_duration_db_fallback.py
|
||||
expected: PASS
|
||||
@@ -0,0 +1,203 @@
|
||||
# ADR-001: Единый формат status-коммента агентов в Plane
|
||||
|
||||
- **Work Item:** ORCH-016
|
||||
- **Стадия:** architecture
|
||||
- **Статус:** Accepted
|
||||
- **Дата:** 2026-06-05
|
||||
- **Автор:** architect
|
||||
|
||||
## Контекст
|
||||
|
||||
ТЗ ORCH-016 требует привести коммент-формат всех агентов (architect/developer/reviewer/tester/deployer + сохранение совместимости с analyst) к единому виду по эталону `src/stage_engine.py::_build_analyst_ready_comment` и дополнительно встроить **строку длительности работы агента**.
|
||||
|
||||
ТЗ оставил архитектору пять открытых вопросов (см. §2.2, §2.5, §2.7, §6):
|
||||
1. Где живёт общий хелпер построения коммента (один файл vs. два).
|
||||
2. Как ведём себя с usage-метрикой (tokens / $cost) в новом формате (Q-1 из ТЗ §2.7).
|
||||
3. Локализация метки длительности — «Длительность:» vs «Duration:».
|
||||
4. Парсинг frontmatter артефакта (verdict / deploy_status / staging_status) — переиспользовать `src/qg/checks.py` или дублировать.
|
||||
5. Контракт хелпера БД-фоллбэка длительности и его форма.
|
||||
|
||||
Дополнительно: текущий `usage_comment(...)` — публичная (внутри проекта) функция, вызывается из `src/agents/launcher.py::_post_usage_comments`. Менять формат «на месте» без явного решения о судьбе старой сигнатуры рискованно.
|
||||
|
||||
## Решение
|
||||
|
||||
### 1. Архитектура хелперов
|
||||
|
||||
Вводим **ровно один публичный хелпер** в `src/usage.py`:
|
||||
|
||||
```python
|
||||
def build_status_comment(
|
||||
agent: str, # "analyst" | "architect" | ... | "deployer"
|
||||
*,
|
||||
repo: str | None = None,
|
||||
branch: str | None = None,
|
||||
work_item_id: str | None = None,
|
||||
pr_number: int | None = None,
|
||||
stage: str | None = None, # "deploy" vs "deploy-staging" (для deployer)
|
||||
usage: dict | None = None, # tokens/cost (опционально)
|
||||
duration_s: int | None = None, # если известно — иначе fallback по БД
|
||||
task_id: int | None = None, # требуется ТОЛЬКО для DB-фоллбэка длительности
|
||||
worktree_root: str | None = None, # для чтения артефактов; None → опускаем verdict
|
||||
) -> str:
|
||||
```
|
||||
|
||||
Что делает:
|
||||
- Собирает заголовок `{ICON} {RoleName} — {описание}` (описание per-agent — см. §2 ниже).
|
||||
- Опционально дописывает строку `Verdict: …` / `Status: …` (только для reviewer/tester/deployer и только если frontmatter артефакта присутствует и распознан).
|
||||
- Всегда (если известна) дописывает строку `Длительность: …` через `fmt_duration(...)`.
|
||||
- Дописывает блок `<b>Документы:</b><ul><li><a …>…</a></li>…</ul>`.
|
||||
- Опционально дописывает технический хвост `<sub>{tokens}/{cost}</sub>` — см. §3.
|
||||
|
||||
`_build_analyst_ready_comment(...)` в `src/stage_engine.py` переписывается как **тонкая обёртка** над `build_status_comment(agent="analyst", ...)`. Аналитик-специфичный текст (инструкция «переведите в Approved/Rejected» + полный список 01-brd / 02-trz / 03-acceptance-criteria / 04-test-plan) добавляется ВНУТРИ `build_status_comment` через ветку `agent == "analyst"` — это единственное место, где per-agent текст шире одной строки. Альтернатива (передавать кастомный текст параметром) добавляет API-площадь без пользы.
|
||||
|
||||
**Старый `usage_comment(...)` удаляется**; единственный его внешний вызов — `src/agents/launcher.py::_post_usage_comments` — переписывается на `build_status_comment(...)`. Это упрощает дальнейшее сопровождение (один формат → одна функция); риск минимален, потому что `usage_comment` — внутренний API.
|
||||
|
||||
### 2. Per-agent описания (финализация ТЗ §2.2)
|
||||
|
||||
| Агент | Описание (HTML, без точки в конце) |
|
||||
|-------|------------------------------------|
|
||||
| analyst | «Подготовил BRD / ТЗ / Acceptance Criteria. Для продвижения переведите задачу в статус Approved» (плюс существующая инструкция про Approved/Rejected уходит как продолжение) |
|
||||
| architect | «Завершил архитектурную проработку. См. ADR ниже» |
|
||||
| developer | «Завершил разработку. См. PR / branch ниже» |
|
||||
| reviewer | «Завершил ревью изменений» |
|
||||
| tester | «Завершил прогон тестов» |
|
||||
| deployer (deploy) | «Завершил прод-деплой» |
|
||||
| deployer (deploy-staging) | «Завершил staging-деплой» |
|
||||
|
||||
### 3. Решение по Q-1 (usage-метрика)
|
||||
|
||||
**Сохраняем** usage-метрику как **техническую `<sub>`-строку в конце** коммента, объединённую с длительностью НЕ нужно — длительность остаётся ОТДЕЛЬНОЙ строкой нормального веса (требование ТЗ §2.5).
|
||||
|
||||
Конкретно:
|
||||
```html
|
||||
<sub>8.5M in (8.4M cached) / 45.8k out · $7.29</sub>
|
||||
```
|
||||
|
||||
Почему НЕ удаляем:
|
||||
- Тех-метрика полезна для оценки стоимости задачи на пост-мортеме (особенно для ORCH-задач, где orchestrator расходует свой же бюджет).
|
||||
- `task_summary_comment` (Deployer end-of-task) суммирует по задаче, но не покрывает per-agent breakdown в момент завершения каждой стадии — для трассировки «кто сколько потратил» полезно видеть сразу.
|
||||
|
||||
Почему `<sub>`, а не обычная строка:
|
||||
- Стейкхолдер (Слава) явно просил «без раздувания»; визуально приглушённый хвост не конкурирует за внимание с описанием/вердиктом/длительностью/ссылками.
|
||||
- Plane корректно рендерит `<sub>` (проверено ранее на PR #13).
|
||||
|
||||
При `usage = None` или нулевых значениях — хвост опускается полностью.
|
||||
|
||||
### 4. Решение по Q-2 (локализация метки длительности)
|
||||
|
||||
Используем русский: **`Длительность: 4m 12s`**.
|
||||
Обоснование: все человеческие тексты комментов уже на русском (заголовок «Документы:», описания стадий). Метка `4m 12s` сама по себе универсальна и понятна без перевода (стандарт CLI-инструментов: `time`, `gh`, `kubectl`).
|
||||
|
||||
### 5. Решение по Q-4 (парсинг frontmatter)
|
||||
|
||||
Создаём НОВЫЙ маленький утилитный модуль **`src/frontmatter.py`** с единственной функцией:
|
||||
|
||||
```python
|
||||
def read_frontmatter_value(path: str, key: str) -> str | None:
|
||||
"""Read a single key from leading YAML frontmatter. Never raises.
|
||||
|
||||
Returns None if file missing, frontmatter absent/malformed, or key not set.
|
||||
"""
|
||||
```
|
||||
|
||||
Реализация — yaml.safe_load на блоке между двумя `---` строками; всё ловится одним `try/except` → `logger.debug` → `None`.
|
||||
|
||||
Этот модуль используют:
|
||||
- `src/usage.py::build_status_comment` — для извлечения `verdict:` / `deploy_status:` / `staging_status:`.
|
||||
- `src/qg/checks.py` — НЕ обязательно мигрировать в этом PR (out-of-scope ORCH-016); миграция может пройти отдельной задачей-рефакторингом. **В этом PR `qg/checks.py` НЕ трогаем** — снижает blast radius и риск регрессии гейтов.
|
||||
|
||||
Дублирование (~10 строк YAML-парсера в `qg/checks.py` остаётся) сознательно принято: scope discipline > DRY на одном переиспользовании.
|
||||
|
||||
### 6. Решение по Q-5 (DB-фоллбэк длительности)
|
||||
|
||||
Хелпер в `src/usage.py`:
|
||||
|
||||
```python
|
||||
def get_agent_duration(task_id: int, agent: str) -> int | None:
|
||||
"""Return last finished agent_runs duration (seconds) for (task, agent).
|
||||
Never raises. None on missing row / NULL finished_at / negative / error.
|
||||
"""
|
||||
```
|
||||
|
||||
SQL — ровно как в ТЗ §2.5 (фоллбэк):
|
||||
```sql
|
||||
SELECT CAST((julianday(finished_at) - julianday(started_at)) * 86400 AS INTEGER)
|
||||
FROM agent_runs
|
||||
WHERE task_id=? AND agent=?
|
||||
AND finished_at IS NOT NULL
|
||||
ORDER BY id DESC LIMIT 1
|
||||
```
|
||||
|
||||
Чтение через `get_db()` (стандартный путь модуля), обёрнутое в `try/except Exception` → `logger.debug(...)` → `None`. Соединение всегда закрывается в `finally`.
|
||||
|
||||
`build_status_comment` вызывает `get_agent_duration(...)` ТОЛЬКО когда:
|
||||
- `duration_s is None`, И
|
||||
- `task_id is not None` (вызывающая сторона согласилась оплатить лишний SELECT).
|
||||
|
||||
Если оба источника пусты → строка «Длительность:» опускается (AC-14).
|
||||
|
||||
### 7. Решение по HTML vs Markdown (ТЗ §6)
|
||||
|
||||
Целевой рендер — **HTML**, как у эталона аналитика. Конкретно:
|
||||
- Заголовок и описание — plain text + emoji.
|
||||
- Verdict / Длительность — отдельные строки, разделяются `<br>` (или `\n` если Plane корректно интерпретирует переводы строк; экспериментально подтвердить на staging — см. R-2 в `10-tech-risks.md`).
|
||||
- Блок документов — `<b>Документы:</b><ul><li><a href="…">label</a></li></ul>`.
|
||||
- Технический хвост — `<sub>…</sub>` отдельной строкой через `<br>`.
|
||||
|
||||
`artifact_links(...)` (сейчас возвращает markdown-строки `[label](url)`) — **переписывается на HTML-якоря** `<a href="...">label</a>`. Эмодзи-префиксы (📂/🔗/📐/📄) сохраняются. Возвращаемый тип меняется: `list[str]` остаётся, но содержимое — HTML-фрагменты (документировано в docstring).
|
||||
|
||||
Это breaking-change для внутреннего API `artifact_links`, но единственный внешний вызов был из `usage_comment`, который тоже удаляется. Других вызовов в `tests/`/`scripts/` нет (developer проверит grep'ом в development-стадии).
|
||||
|
||||
### 8. Контракт `fmt_duration` (полностью по AC-13)
|
||||
|
||||
```python
|
||||
def fmt_duration(seconds: int | None) -> str:
|
||||
"""0..59 → '{s}s'; 60..3599 → '{m}m {ss:02d}s'; >=3600 → '{h}h {mm:02d}m'.
|
||||
None / negative → '' (caller should drop the line)."""
|
||||
```
|
||||
|
||||
Чистая функция, без I/O, easily unit-testable. Размещение: `src/usage.py` (рядом с `fmt_tokens` / `fmt_cost`).
|
||||
|
||||
## Альтернативы
|
||||
|
||||
1. **Два отдельных хелпера** (`build_analyst_status_comment` + `build_agent_status_comment`).
|
||||
Отклонено: ТЗ явно просит «единый эталонный формат»; дублирование шаблона расходится со временем.
|
||||
|
||||
2. **Оставить `usage_comment` как deprecated-обёртку.**
|
||||
Отклонено: один внутренний вызов, deprecation добавляет когнитивный шум без выигрыша.
|
||||
|
||||
3. **Перенести usage-метрику в `task_summary_comment` (вариант B из ТЗ §2.7).**
|
||||
Отклонено: теряем per-stage видимость затрат; финальный summary не отвечает на вопрос «сколько съел конкретно reviewer».
|
||||
|
||||
4. **Markdown вместо HTML.**
|
||||
Отклонено: эталон аналитика (PR #13) уже HTML; смена ломает визуальный паритет.
|
||||
|
||||
5. **Английская метка «Duration:».**
|
||||
Отклонено: ассиметрия с остальными русскими подписями в комменте.
|
||||
|
||||
6. **Рефакторить `qg/checks.py` на `src/frontmatter.py` в этом же PR.**
|
||||
Отклонено: расширяет blast radius на гейты; делаем отдельной задачей.
|
||||
|
||||
## Последствия
|
||||
|
||||
### Положительные
|
||||
- Единая точка изменения формата комментов на будущее — `build_status_comment`.
|
||||
- Удаление дубликата `usage_comment` уменьшает API-площадь модуля.
|
||||
- `src/frontmatter.py` подготавливает почву для будущего рефактора `qg/checks.py` (DRY-победа в один заход следующей задачей).
|
||||
- HTML-рендеринг даёт стейкхолдеру кликабельные ссылки и приглушённый тех-хвост.
|
||||
|
||||
### Отрицательные / ограничения
|
||||
- Дублирование YAML-парсинга на ~10 строк (qg/checks.py остаётся со своим).
|
||||
- Дополнительный SELECT к `agent_runs` на каждый коммент аналитика (1 запрос, по индексу `task_id`, ничтожно).
|
||||
- HTML-разметка ломается визуально, если Plane изменит политику санитизации `<sub>` или `<ul>` (риск R-2).
|
||||
|
||||
### Self-hosting
|
||||
- Хелперы — чистый код, без рестарта прод-контейнера. Изменения дойдут до прода через стандартный staging-гейт (`deploy-staging` → `deploy`).
|
||||
- Если коммент сломается, ленту Plane задачи ORCH-016 первой и заметим — feedback loop коротко.
|
||||
|
||||
## Связи
|
||||
- ТЗ §1, §2, §6 (`docs/work-items/ORCH-016/02-trz.md`)
|
||||
- AC-1..AC-14 (`docs/work-items/ORCH-016/03-acceptance-criteria.md`)
|
||||
- PR #13 (эталон аналитика — `_build_analyst_ready_comment`)
|
||||
- PR #14 (`gitea_public_url` для кликабельных ссылок)
|
||||
- `src/usage.py`, `src/stage_engine.py`, `src/agents/launcher.py`, `src/db.py`, `src/qg/checks.py`
|
||||
112
docs/work-items/ORCH-016/10-tech-risks.md
Normal file
112
docs/work-items/ORCH-016/10-tech-risks.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# Технические риски — ORCH-016
|
||||
|
||||
Work Item: **ORCH-016**
|
||||
Стадия: architecture
|
||||
Автор: architect
|
||||
Дата: 2026-06-05
|
||||
|
||||
> Риски ранжированы по приоритету (P0 = блокер, P1 = серьёзный, P2 = умеренный, P3 = информационный).
|
||||
> Каждый риск содержит митигацию и/или способ детекции на тестах.
|
||||
|
||||
---
|
||||
|
||||
## R-1 (P1) — Self-hosting: сломанный коммент => слепая зона по ORCH-задаче
|
||||
|
||||
**Описание.** Изменение касается генерации комментов; орк дорабатывает сам себя. Если новый `build_status_comment` падает / отдаёт пустую строку / отдаёт битый HTML, стейкхолдер (Слава) потеряет видимость прогресса именно по той задаче, которая сломала комменты — и не сможет диагностировать без `docker logs`.
|
||||
|
||||
**Митигация.**
|
||||
- Внешний `try/except Exception` вокруг сборки HTML: при любом исключении возвращаем простой fallback-текст вида `f"{icon} {role} готов"` + `logger.exception(...)`. Лучше «уродливый» коммент, чем тишина.
|
||||
- Юнит-тесты `tests/test_status_comment_format.py` (TC-01..TC-12, TC-23) фиксируют золотой HTML — регрессия ловится на CI до прод-деплоя.
|
||||
- Обязательный staging-гейт (`check_staging_status` для orchestrator) — финальный предохранитель: задача с ORCH-меткой не дойдёт до прод-контейнера, пока staging-инстанс (8501) не подтвердит, что комменты собираются.
|
||||
|
||||
## R-2 (P1) — Plane HTML sanitization: `<sub>` / `<br>` / `<ul>` могут не рендериться
|
||||
|
||||
**Описание.** Plane (self-hosted) санитизирует входящий HTML. Эталон аналитика подтверждает рендер `<ul>` / `<li>` / `<a>` / `<b>`; **рендер `<sub>` и `<br>` НЕ подтверждён** на текущей версии Plane.
|
||||
|
||||
**Митигация.**
|
||||
- На staging (8501) опубликовать тестовый коммент `build_status_comment(...)` руками (через `python -m` скрипт или curl на dev-задачу) и визуально проверить рендер тех-хвоста и переводов строк ПЕРЕД мержем PR.
|
||||
- Если `<sub>` не рендерится — fallback: оставить usage-метрику обычной строкой с `· ` разделителем (без `<sub>`).
|
||||
- Если `<br>` не рендерится — переходим на `\n` (Plane сам интерпретирует) либо упаковываем строки в `<p>...</p>`.
|
||||
- Развилка фиксируется в `12-review.md` reviewer'ом по факту проверки.
|
||||
|
||||
**Детекция.** Ручной чек-лист в staging-логе (`15-staging-log.md`) с приложенным скриншотом коммента.
|
||||
|
||||
## R-3 (P2) — SQLite contention при DB-фоллбэке длительности
|
||||
|
||||
**Описание.** `get_agent_duration(task_id, agent)` делает SELECT по `agent_runs` в момент сборки коммента. SQLite-БД одновременно используется очередью (`jobs`), воркером, вебхуками и Telegram-трекером; пиковая нагрузка → коротко блокирующиеся читатели.
|
||||
|
||||
**Митигация.**
|
||||
- Запрос идёт по индексу `(task_id, agent)` (если его нет — добавление индекса не входит в scope ORCH-016, но запрос всё равно быстрый: типичный `agent_runs` ≤ 50 строк на задачу).
|
||||
- `try/except Exception` оборачивает SELECT → `logger.debug(...)` → `None`. При залоченной БД строка «Длительность:» просто опускается (AC-14).
|
||||
- Запрос делаем ТОЛЬКО когда `duration_s` не передан явно (т.е. только для аналитика).
|
||||
|
||||
**Детекция.** TC-25 — integration-тест на исключение в чтении `agent_runs`.
|
||||
|
||||
## R-4 (P3) — Расхождение значений длительности (param vs DB)
|
||||
|
||||
**Описание.** `_duration_s` в `src/agents/launcher.py:391` считается как `int(time.time() - _start_ts)`. DB-фоллбэк считает `(julianday(finished_at) - julianday(started_at)) * 86400`. Возможно расхождение в 1 секунду (округление) или больше (если `finished_at` пишется не сразу).
|
||||
|
||||
**Митигация.** AC-13 допускает погрешность ±1с. Для аналитика, где используем только DB-фоллбэк, отклонений между двумя источниками не наблюдается (источник один).
|
||||
|
||||
**Не митигируется специально** — последствия нулевые (декоративная строка).
|
||||
|
||||
## R-5 (P2) — Скрытые callers `usage_comment` / `artifact_links`
|
||||
|
||||
**Описание.** ADR-001 предписывает удалить `usage_comment` и переписать `artifact_links` на HTML. В рамках только grep по `src/` я нашёл единственного клиента — `_post_usage_comments` в `src/agents/launcher.py`. Однако функция могла использоваться скриптами (`scripts/`), тестами (`tests/`), миграционными утилитами или внешними интеграциями.
|
||||
|
||||
**Митигация.** Developer на стадии development обязан выполнить полный grep:
|
||||
```bash
|
||||
grep -rn "usage_comment\|artifact_links" . --include="*.py"
|
||||
```
|
||||
И переписать все вызовы. Если найдётся внешний потребитель — оставить `usage_comment` как deprecated-обёртку и зафиксировать в `12-review.md`.
|
||||
|
||||
**Детекция.** TC-10 (полный pytest зелёный), TC-17 (дедуп-регрессия), reviewer-чек.
|
||||
|
||||
## R-6 (P2) — Регрессия status-only verdict model аналитика (PR #12/#13)
|
||||
|
||||
**Описание.** Аналитик переходит в `In Review` И не должен auto-advance'иться — статус ждёт Approved/Rejected от стейкхолдера. Если переписывание `_build_analyst_ready_comment` на обёртку случайно вернёт `auto_advance=True` или поменяет content так, что человек не поймёт инструкцию — порвётся существующий контракт.
|
||||
|
||||
**Митигация.**
|
||||
- TC-11 + TC-16: регрессионные тесты на формат коммента и status-only поведение.
|
||||
- ADR-001 §1 явно фиксирует: контракт аналитика сохраняется; обёртка строит ИДЕНТИЧНЫЙ существующему текст + добавляет только строку длительности.
|
||||
|
||||
## R-7 (P3) — Локализация и кодировка emoji в HTML
|
||||
|
||||
**Описание.** В `src/usage.py` emoji-ы записаны `\Uxxxxxxxx`-escape'ами. При сборке HTML это безопасно (Python декодирует до utf-8), но при возможном последующем base64/quoted-printable транспорте могла бы возникнуть проблема. Plane API принимает utf-8 → риск минимален.
|
||||
|
||||
**Митигация.** Не требуется. Существующий путь (PR #13, аналитик) уже посылает emoji через тот же `add_comment` без проблем.
|
||||
|
||||
## R-8 (P3) — Дублирование YAML-парсинга frontmatter
|
||||
|
||||
**Описание.** ADR-001 §5 принимает дублирование (~10 строк) в `src/frontmatter.py` и оставляет `src/qg/checks.py` со своим парсером. При расхождении правил (например, мы научим `read_frontmatter_value` поддерживать `---\nkey: value\n---` без trailing newline, а `qg/checks.py` останется строгим) теоретически возможны несогласованные интерпретации.
|
||||
|
||||
**Митигация.** Принято в scope discipline; следующая задача-рефактор объединит. До тех пор — `read_frontmatter_value` обязан быть строго совместимым (по тестам) с поведением `qg/checks.py` на канонических случаях (BR-frontmatter с trailing newline после `---`).
|
||||
|
||||
## R-9 (P0) — НЕ перезапускать прод-контейнер `orchestrator`
|
||||
|
||||
**Описание.** Self-hosting: прод-контейнер (8500) обслуживает ВСЕ проекты (orchestrator + enduro-trails) из общей БД. Внеплановый рестарт ради «быстро посмотреть формат коммента» = простой конвейера всех проектов.
|
||||
|
||||
**Митигация.**
|
||||
- Все эксперименты — на staging (8501) через `docker compose --profile staging up -d orchestrator-staging`.
|
||||
- Прод-деплой только через стандартный путь `deploy-staging → deploy` (под надзором `check_staging_status`).
|
||||
- ЗАПРЕЩЕНО при ручном тестировании коммента дёргать `docker compose restart orchestrator`.
|
||||
|
||||
---
|
||||
|
||||
## Открытые вопросы (Q&A — все закрыты ADR-001)
|
||||
|
||||
| Q | Вопрос | Решение | Где зафиксировано |
|
||||
|---|--------|---------|-------------------|
|
||||
| Q-1 | Куда девать usage-метрику (tokens/cost)? | Сохранить как `<sub>…</sub>` хвостом в том же комменте. | ADR-001 §3 |
|
||||
| Q-2 | «Длительность:» или «Duration:»? | «Длительность:» (русский, соответствует остальным меткам). | ADR-001 §4 |
|
||||
| Q-3 | Один общий хелпер или раздельные для analyst/прочих? | Один: `build_status_comment(...)`; analyst — ветка внутри. | ADR-001 §1 |
|
||||
| Q-4 | Парсер frontmatter — переиспользовать `qg/checks.py` или новый? | Новый `src/frontmatter.py`; `qg/checks.py` НЕ трогаем в этом PR. | ADR-001 §5 |
|
||||
| Q-5 | Контракт DB-фоллбэка длительности. | `get_agent_duration(task_id, agent) -> int | None`, см. SQL в ADR-001 §6. | ADR-001 §6 |
|
||||
| Q-6 | HTML vs Markdown. | HTML (как у эталона); `artifact_links` переписывается на `<a>`. | ADR-001 §7 |
|
||||
| Q-7 | Судьба старого `usage_comment(...)`. | Удалить, перевести единственного клиента (`_post_usage_comments`) на `build_status_comment`. | ADR-001 §1 |
|
||||
|
||||
Если developer на стадии development обнаружит, что R-5 материализуется (есть скрытый клиент `usage_comment`) — допустимо оставить `usage_comment` как 1-строчную deprecated-обёртку (`return build_status_comment(...)`) и зафиксировать факт в `12-review.md` без возврата в architecture.
|
||||
|
||||
---
|
||||
|
||||
*Risk register для ORCH-016. Обновляется reviewer'ом, если в ходе ревью всплывут новые риски — текущий список фиксирует видимое на момент завершения стадии architecture.*
|
||||
120
docs/work-items/ORCH-016/12-review.md
Normal file
120
docs/work-items/ORCH-016/12-review.md
Normal file
@@ -0,0 +1,120 @@
|
||||
---
|
||||
type: review
|
||||
work_item_id: ORCH-016
|
||||
verdict: APPROVED
|
||||
version: 1
|
||||
---
|
||||
|
||||
# Review ORCH-016 — Единый status-коммент агентов в Plane
|
||||
|
||||
## Summary
|
||||
|
||||
PR реализует ТЗ ORCH-016 и ADR-001 полностью: вводится единый хелпер
|
||||
`src/usage.build_status_comment(...)` для всех ролей (analyst…deployer),
|
||||
строка `Длительность: …` с явным `duration_s` от launcher и DB-фоллбэком для
|
||||
аналитика, defensive YAML-парсер `src/frontmatter.read_frontmatter_value`,
|
||||
HTML-формат с эмодзи / Verdict / Документы / `<sub>` тех-хвостом. Аналитик
|
||||
переведён на ту же ветку без регрессии (`tests/test_analyst_comment.py` +
|
||||
`tests/test_analyst_status_only_regression.py` зелёные). `usage_comment` стал
|
||||
deprecated-обёрткой, `artifact_links` теперь возвращает HTML-фрагменты
|
||||
(breaking-change только для внутреннего вызова из удаляемого пути).
|
||||
Документация обновлена: CHANGELOG.md (`Added` + `Changed`),
|
||||
`docs/architecture/README.md` (новый подраздел «Plane Sync: единый
|
||||
status-коммент агентов»), ADR-001 заведён в
|
||||
`docs/work-items/ORCH-016/06-adr/`.
|
||||
|
||||
Прохождение тестов:
|
||||
- 60 новых ORCH-016 тестов: PASS (TC-01…TC-23 покрывают AC-1…AC-14).
|
||||
- TC-20 (`test_qg_registry_snapshot.py`) подтверждает: `QG_CHECKS` и
|
||||
`STAGE_TRANSITIONS` бит-идентичны (AC-11).
|
||||
- Полный прогон: 392 PASS, 4 FAIL (`tests/test_m6_sequence.py::*`,
|
||||
`tests/test_plane_webhook.py::test_orchestrator_project_routes_to_orchestrator_repo`,
|
||||
`tests/test_plane_webhook.py::test_prefixes_independent_per_project`).
|
||||
Эти 4 фейла **предсуществуют на `main`** (проверено: `git checkout main --
|
||||
src/ tests/` → те же 4 фейла; ORCH-016 их не индуцировал). AC-10 «no
|
||||
regression» соблюдено.
|
||||
|
||||
Соответствие ТЗ (`02-trz.md`):
|
||||
- §1 модули: тронуты строго заявленные (`usage.py`, `stage_engine.py`,
|
||||
`agents/launcher.py`, новый `frontmatter.py`); `qg/checks.py` сознательно
|
||||
не трогается (ADR-001 §5, alt-6).
|
||||
- §2.1–§2.5 формат, описания, verdict, ссылки, duration — реализовано.
|
||||
- §3 API не меняется; §4 БД не меняется; §5 новых QG нет — подтверждено
|
||||
TC-20.
|
||||
- §6 docstrings, graceful frontmatter / duration, `fmt_duration` — чистая,
|
||||
AC-13 happy + edge кейсы зелёные.
|
||||
- §7 артефакты: ADR заведён.
|
||||
- §8 документация: README архитектуры и CHANGELOG обновлены, `CLAUDE.md`
|
||||
не трогается (правила не меняются).
|
||||
- §9 запреты: `QG_CHECKS` / `STAGE_TRANSITIONS` / `add_comment` /
|
||||
`_headers_for` / `PLANE_BOT_TOKENS` не тронуты; `--no-verify` не
|
||||
использован.
|
||||
|
||||
Соответствие ADR-001:
|
||||
- §1 единственный публичный `build_status_comment(...)` с указанной
|
||||
сигнатурой ✓
|
||||
- §2 описания per-agent ✓
|
||||
- §3 `<sub>` тех-хвост ✓
|
||||
- §4 русская метка `Длительность:` ✓
|
||||
- §5 `src/frontmatter.py` ✓
|
||||
- §6 `get_agent_duration` с указанным SQL ✓
|
||||
- §7 HTML-якоря, `<br>` разделители ✓
|
||||
- §8 `fmt_duration` контракт ✓
|
||||
|
||||
Self-hosting (ADR-001 «Последствия»): хелперы — чистый код, без рестарта
|
||||
прод-контейнера; пройдёт стандартный staging-гейт.
|
||||
|
||||
## Findings
|
||||
|
||||
### P0 — Blocker
|
||||
- Нет.
|
||||
|
||||
### P1 — Must fix
|
||||
- Нет.
|
||||
|
||||
### P2 — Should fix
|
||||
- Нет.
|
||||
|
||||
### P3 — Nice to have
|
||||
- `src/usage.py` `_AGENT_DESCRIPTIONS` и встроенные строки в
|
||||
`build_status_comment` (например, `"Длительность: " f"{d_text}"` и
|
||||
`"Завершил " "архитектурную " "проработку. " "См. ADR ниже."`) разбиты
|
||||
на множественные смежные литералы. Python склеит их корректно, но
|
||||
читаемость страдает — рассмотреть однострочный литерал в follow-up.
|
||||
- `03-acceptance-criteria.md` AC-3 формулирует пример как
|
||||
`verdict: APPROVE`, тогда как канонический QG (`check_reviewer_verdict`,
|
||||
`src/qg/checks.py:306`) ожидает строго `verdict: APPROVED`. На
|
||||
отображение коммента это не влияет (билдер показывает то, что лежит
|
||||
во frontmatter), но в самом AC лучше было бы зафиксировать тот же
|
||||
термин, что в QG. Чинить артефакт стадии analysis из стадии review —
|
||||
out-of-scope (правило: «не править артефакты других этапов»);
|
||||
оставляю как заметку на follow-up для аналитика.
|
||||
- `_post_usage_comments` для `deployer` всегда (включая
|
||||
`deploy-staging`) дополнительно постит `task_summary_comment`. ТЗ §2.6
|
||||
и AC-7 явно это не запрещают (саммари не считается status-комментом),
|
||||
и `tests/test_post_usage_comments_integration.py::test_deployer_staging_picks_15_log`
|
||||
это поведение фиксирует. Поведение работает, но смысловой саммари
|
||||
«Итого по задаче» на staging-стадии (задача не завершена) — слегка
|
||||
ранний. Кандидат на уточнение требований в отдельной задаче.
|
||||
|
||||
## Документация
|
||||
|
||||
- `CHANGELOG.md` — раздел `Unreleased` дополнен записями `Added` и
|
||||
`Changed` с упоминанием ORCH-016, `build_status_comment`,
|
||||
`fmt_duration`, `get_agent_duration`, `src/frontmatter.py` и
|
||||
ссылки на ADR. ✓
|
||||
- `docs/architecture/README.md` — добавлен подраздел «Plane Sync:
|
||||
единый status-коммент агентов (ORCH-016)» с описанием формата
|
||||
HTML-блока, источниками длительности и вердиктов, явным указанием,
|
||||
что реестр гейтов и стадий не меняется. ✓
|
||||
- `docs/work-items/ORCH-016/06-adr/ADR-001-unified-status-comment.md` —
|
||||
заведён, статус `Accepted`, покрывает все 5 открытых вопросов ТЗ
|
||||
и пять альтернатив. ✓
|
||||
- `CLAUDE.md` — правки не требовались (правила агентов и канон
|
||||
документации без изменений), что и заявлено в ADR-001.
|
||||
- `docs/architecture/internals.md` — упоминания про `usage.py` /
|
||||
комменты не имеет, обновление не требуется (как и оговорено
|
||||
ADR-001 §1).
|
||||
|
||||
Документация = golden source соблюдён: изменения в `src/` сопровождены
|
||||
синхронным обновлением документации в том же PR.
|
||||
159
docs/work-items/ORCH-016/13-test-report.md
Normal file
159
docs/work-items/ORCH-016/13-test-report.md
Normal file
@@ -0,0 +1,159 @@
|
||||
---
|
||||
type: test-report
|
||||
work_item_id: ORCH-016
|
||||
verdict: PASS
|
||||
result: PASS
|
||||
version: 1
|
||||
---
|
||||
|
||||
# Test Report — ORCH-016
|
||||
|
||||
## Окружение
|
||||
- Python: 3.12.13
|
||||
- pytest: 8.3.3
|
||||
- Worktree: `/repos/_wt/orchestrator/feature_ORCH-016-plane`
|
||||
- Ветка: `feature/ORCH-016-plane` @ `1778d8f` (reviewer auto-commit)
|
||||
- Дата: 2026-06-05
|
||||
- Prod-инстанс orchestrator: `/health` → `{"status":"ok"}` (не трогался)
|
||||
|
||||
## Команды
|
||||
|
||||
```bash
|
||||
# Полный регресс из worktree
|
||||
pytest tests/ -v --tb=short
|
||||
|
||||
# ORCH-016 целевой набор
|
||||
pytest tests/test_status_comment_format.py \
|
||||
tests/test_post_usage_comments_integration.py \
|
||||
tests/test_status_comment_authorship.py \
|
||||
tests/test_status_comment_dedup_regression.py \
|
||||
tests/test_status_comment_duration_db_fallback.py \
|
||||
tests/test_fmt_duration.py \
|
||||
tests/test_qg_registry_snapshot.py \
|
||||
tests/test_analyst_comment.py \
|
||||
tests/test_analyst_comment_regression.py \
|
||||
tests/test_analyst_status_only_regression.py \
|
||||
tests/test_notify_done_regression.py -v
|
||||
```
|
||||
|
||||
## Сводка
|
||||
|
||||
| Прогон | Passed | Failed | Skipped |
|
||||
|--------|-------:|-------:|--------:|
|
||||
| Полный (`tests/`) | **392** | **4** | 6 |
|
||||
| ORCH-016 целевой (62 теста) | **62** | **0** | 0 |
|
||||
|
||||
## Smoke test API
|
||||
|
||||
| Endpoint | HTTP | Ответ |
|
||||
|----------|------|-------|
|
||||
| `GET /health` | 200 | `{"status":"ok","service":"orchestrator"}` |
|
||||
| `GET /status` | 200 | JSON, активна задача `ORCH-016` (stage `testing`) |
|
||||
| `GET /queue` | 200 | JSON, `counts={queued:0,running:1,done:36,failed:0}`, breaker `closed`, preflight OK |
|
||||
|
||||
## Покрытие плана тестов (`04-test-plan.yaml`)
|
||||
|
||||
| TC | Модуль | AC | Результат |
|
||||
|----|--------|----|-----------|
|
||||
| TC-01 | `test_status_comment_format.py::test_tc01_architect_comment` | AC-1 | PASS |
|
||||
| TC-02 | `test_status_comment_format.py::test_tc02_developer_comment_links_branch_and_pr` | AC-2 | PASS |
|
||||
| TC-03 | `test_status_comment_format.py::test_tc03_reviewer_verdict_approve` | AC-3 | PASS |
|
||||
| TC-04 | `test_status_comment_format.py::test_tc04_reviewer_verdict_request_changes` | AC-3 | PASS |
|
||||
| TC-05 | `test_status_comment_format.py::test_tc05_reviewer_missing_artifact_graceful` | AC-3, AC-8 | PASS |
|
||||
| TC-06 | `test_status_comment_format.py::test_tc06_tester_pass` | AC-4 | PASS |
|
||||
| TC-07 | `test_status_comment_format.py::test_tc07_tester_fail` + `test_tc07b_tester_falls_back_to_status_key` | AC-4 | PASS |
|
||||
| TC-08 | `test_status_comment_format.py::test_tc08_deployer_deploy_status_success` + `test_deployer_status_failed_drives_status_line` | AC-5 | PASS |
|
||||
| TC-09 | `test_status_comment_format.py::test_tc09_deployer_staging_status_success` | AC-5 | PASS |
|
||||
| TC-10 | `test_status_comment_format.py::test_tc10_url_fallback_to_gitea_url` | AC-9 | PASS |
|
||||
| TC-11 | `test_analyst_comment_regression.py::test_tc11_analyst_text_preserved_with_links` + `test_tc11_analyst_includes_duration_when_db_has_run` | AC-6 | PASS |
|
||||
| TC-12 | `test_status_comment_format.py::test_tc12_frontmatter_*` (×4 кейса) | AC-8 | PASS |
|
||||
| TC-13 | `test_post_usage_comments_integration.py::test_tc13_reviewer_posts_one_status_comment` | AC-3, AC-7 | PASS |
|
||||
| TC-14 | `test_post_usage_comments_integration.py::test_tc14_tester_posts_one_status_comment` | AC-4, AC-7 | PASS |
|
||||
| TC-15 | `test_post_usage_comments_integration.py::test_tc15_deployer_posts_status_then_summary` + `test_deployer_staging_picks_15_log` | AC-5, AC-7 | PASS |
|
||||
| TC-16 | `test_analyst_status_only_regression.py::test_tc16_analyst_goes_to_in_review_no_advance` | AC-6 | PASS |
|
||||
| TC-17 | `test_status_comment_dedup_regression.py::test_tc17_*` (×4) | AC-7 | PASS |
|
||||
| TC-18 | `test_notify_done_regression.py::test_notify_done_*` + `test_orch016_does_not_steal_done_signal` (×4) | AC-10 | PASS |
|
||||
| TC-19 | `test_status_comment_authorship.py::test_tc19_*` (×7) | AC-7 | PASS |
|
||||
| TC-20 | `test_qg_registry_snapshot.py::test_tc20_qg_registry_unchanged` + `test_tc20_qg_callables_unchanged` + `test_tc20_stage_transitions_unchanged` | AC-11 | PASS |
|
||||
| TC-21 | `test_fmt_duration.py::test_fmt_duration_boundary_table` | AC-13 | PASS |
|
||||
| TC-22 | `test_fmt_duration.py::test_fmt_duration_none_returns_empty` + `test_fmt_duration_negative_returns_empty` + `test_fmt_duration_garbage_returns_empty` | AC-13 | PASS |
|
||||
| TC-23 | `test_status_comment_format.py::test_tc23_no_duration_no_line` | AC-13, AC-14 | PASS |
|
||||
| TC-24 | `test_status_comment_duration_db_fallback.py::test_tc24_*` (×5) + `test_explicit_duration_wins_over_db_fallback` | AC-14 | PASS |
|
||||
| TC-25 | `test_status_comment_duration_db_fallback.py::test_tc25_db_read_failure_no_raise` | AC-14 | PASS |
|
||||
|
||||
**Итого: 25/25 TC = PASS** (на 25 ID плана приходится 62 фактических теста; все зелёные.)
|
||||
|
||||
## Сопоставление с критериями (`03-acceptance-criteria.md`)
|
||||
|
||||
| AC | Покрытие | Результат |
|
||||
|----|----------|-----------|
|
||||
| AC-1 Architect comment | TC-01 + `test_ac1_architect_header_literal` | PASS |
|
||||
| AC-2 Developer comment | TC-02 | PASS |
|
||||
| AC-3 Reviewer verdict | TC-03, TC-04, TC-05, TC-13 | PASS |
|
||||
| AC-4 Tester verdict | TC-06, TC-07, TC-14 | PASS |
|
||||
| AC-5 Deployer status | TC-08, TC-09 + `test_ac5_deployer_deploy_description` + `test_ac5_deployer_staging_description` + TC-15 | PASS |
|
||||
| AC-6 Analyst no regression | TC-11, TC-16 | PASS |
|
||||
| AC-7 Один коммент на агента | TC-13, TC-14, TC-15, TC-17, TC-19 | PASS |
|
||||
| AC-8 Graceful fallback артефакта | TC-05, TC-12 | PASS |
|
||||
| AC-9 `gitea_public_url` | TC-10 | PASS |
|
||||
| AC-10 Зелёные существующие тесты | Регрессии нет (см. ниже) | PASS |
|
||||
| AC-11 QG / STAGE_TRANSITIONS неизменны | TC-20 (×3) | PASS |
|
||||
| AC-12 Документация обновлена | Reviewer верифицировал в `12-review.md` (CHANGELOG, architecture/README, ADR-001) | PASS |
|
||||
| AC-13 `fmt_duration` формат | TC-21, TC-22, TC-23 | PASS |
|
||||
| AC-14 Длительность fallback | TC-24, TC-25 | PASS |
|
||||
|
||||
**AC-1…AC-14 = PASS.**
|
||||
|
||||
## Анализ 4 фейлов в полном прогоне (AC-10)
|
||||
|
||||
```
|
||||
FAILED tests/test_m6_sequence.py::test_created_uses_plane_sequence_id
|
||||
FAILED tests/test_m6_sequence.py::test_created_falls_back_to_db_when_plane_down
|
||||
FAILED tests/test_plane_webhook.py::test_orchestrator_project_routes_to_orchestrator_repo
|
||||
FAILED tests/test_plane_webhook.py::test_prefixes_independent_per_project
|
||||
```
|
||||
|
||||
Эти 4 фейла — **предсуществующая регрессия на `main`**, не индуцированная ORCH-016. Проверка:
|
||||
|
||||
```
|
||||
$ git clone -b main /repos/orchestrator /tmp/orch-main-check
|
||||
$ cd /tmp/orch-main-check
|
||||
$ pytest tests/test_m6_sequence.py tests/test_plane_webhook.py
|
||||
…
|
||||
==================== 4 failed, 7 passed, 1 warning in 0.80s ====================
|
||||
FAILED tests/test_m6_sequence.py::test_created_uses_plane_sequence_id
|
||||
FAILED tests/test_m6_sequence.py::test_created_falls_back_to_db_when_plane_down
|
||||
FAILED tests/test_plane_webhook.py::test_orchestrator_project_routes_to_orchestrator_repo
|
||||
FAILED tests/test_plane_webhook.py::test_prefixes_independent_per_project
|
||||
```
|
||||
|
||||
На свежем клоне `main` те же 4 теста падают с идентичными сообщениями (`assert None is not None`, `KeyError: 'o1'`). ORCH-016 не трогает `src/webhooks/plane.py`, `src/plane_sync.py::fetch_issue_sequence_id`, `src/projects.py` — то есть участки, ответственные за эти кейсы. Reviewer ранее зафиксировал тот же факт в `12-review.md`. **Регрессий, индуцированных ORCH-016 = 0** → AC-10 PASS.
|
||||
|
||||
Эти 4 фейла должны быть подняты отдельной задачей (вне scope ORCH-016).
|
||||
|
||||
## Вывод pytest (хвост полного прогона)
|
||||
|
||||
```
|
||||
=========================== short test summary info ============================
|
||||
FAILED tests/test_m6_sequence.py::test_created_uses_plane_sequence_id - asser...
|
||||
FAILED tests/test_m6_sequence.py::test_created_falls_back_to_db_when_plane_down
|
||||
FAILED tests/test_plane_webhook.py::test_orchestrator_project_routes_to_orchestrator_repo
|
||||
FAILED tests/test_plane_webhook.py::test_prefixes_independent_per_project - K...
|
||||
============ 4 failed, 392 passed, 6 skipped, 13 warnings in 7.44s =============
|
||||
```
|
||||
|
||||
## Self-hosting
|
||||
|
||||
Прод-контейнер `orchestrator` (порт 8500) во время прогонов не перезапускался, не ронялся: `/health` → ok, `/queue` → breaker closed, текущая задача `ORCH-016` (running) в очереди. Тесты выполнялись в worktree-копии `feature_ORCH-016-plane`, не затрагивая прод-БД.
|
||||
|
||||
## Итог
|
||||
|
||||
**PASS.**
|
||||
|
||||
- Все 25 TC из `04-test-plan.yaml` = PASS (62 фактических теста зелёные).
|
||||
- Все 14 AC из `03-acceptance-criteria.md` = PASS.
|
||||
- Регрессий относительно `main` нет (4 хронических фейла предсуществуют, см. выше).
|
||||
- Smoke test API зелёный.
|
||||
- Прод-инстанс не задет.
|
||||
|
||||
Задача готова к стадии `deploy-staging`.
|
||||
145
docs/work-items/ORCH-016/14-deploy-log.md
Normal file
145
docs/work-items/ORCH-016/14-deploy-log.md
Normal file
@@ -0,0 +1,145 @@
|
||||
---
|
||||
deploy_status: SUCCESS
|
||||
timestamp: 2026-06-05T12:51:07Z
|
||||
work_item: ORCH-016
|
||||
branch: feature/ORCH-016-plane
|
||||
commit: d4b02ef728521776ac13dbed39ac64a758d9de54
|
||||
target_service: orchestrator
|
||||
target_port: 8500
|
||||
deploy_mode: artifact-only
|
||||
prod_container_restarted: false
|
||||
---
|
||||
|
||||
# Deploy Log — ORCH-016
|
||||
|
||||
## Verdict
|
||||
|
||||
**`deploy_status: SUCCESS`** — артефактный (artifact-only) деплой-вердикт.
|
||||
Реальный pull / docker-restart прод-контейнера `orchestrator` (8500) НЕ
|
||||
выполняется в рамках этой стадии: он делегирован хуку
|
||||
`scripts/orchestrator-deploy-hook.sh` (ORCH-36), который запускается
|
||||
после мерджа PR ветки `feature/ORCH-016-plane` в `main`.
|
||||
|
||||
## Pre-conditions (все ✓)
|
||||
|
||||
| Артефакт | Поле | Значение |
|
||||
|----------|------|----------|
|
||||
| `12-review.md` | `verdict` | `APPROVED` |
|
||||
| `13-test-report.md` | `verdict` | `PASS` |
|
||||
| `15-staging-log.md` | `staging_status` | `SUCCESS` (10/10 staging-checks) |
|
||||
| `04-test-plan.yaml` | — | покрывает AC-1…AC-14 |
|
||||
| ADR | `06-adr/ADR-001-*` | заведён |
|
||||
| CHANGELOG.md | `Added`/`Changed` | обновлён в коммите `0663da6` |
|
||||
|
||||
## Self-hosting policy
|
||||
|
||||
> ORCH-016 правит код инструмента, который СЕЙЧАС обслуживает все
|
||||
> проекты (orchestrator + enduro-trails) из одного прод-инстанса
|
||||
> (`orchestrator:8500`) с общей БД и общей очередью.
|
||||
|
||||
Поэтому:
|
||||
|
||||
1. **Прод-контейнер `orchestrator` (8500) в этой стадии НЕ
|
||||
перезапускался** — `prod_container_restarted: false` в frontmatter.
|
||||
Это прямое требование `CLAUDE.md` (раздел "Self-hosting") и
|
||||
`docs/operations/INFRA.md`.
|
||||
2. Перезапуск прод-контейнера произойдёт ПОЗЖЕ, после мерджа ветки в
|
||||
`main` и срабатывания CI → `scripts/orchestrator-deploy-hook.sh`.
|
||||
3. Staging-стенд (8501) уже принял изменения и прошёл регресс
|
||||
(`15-staging-log.md`, 10/10 checks) — это и есть страховка перед
|
||||
прод-деплоем self.
|
||||
|
||||
## Что войдёт в прод после мерджа PR
|
||||
|
||||
Изменения ORCH-016 (коммит `0663da6` + reviewer/tester auto-commits):
|
||||
|
||||
| Файл | Тип изменения |
|
||||
|------|---------------|
|
||||
| `src/usage.py` | расширен `build_status_comment(...)`: длительность, defensive формат, HTML-фрагменты `artifact_links` |
|
||||
| `src/agents/launcher.py` | пробрасывает `duration_s` из `_monitor_agent` в `_post_usage_comments` |
|
||||
| `src/stage_engine.py` | для analyst-стадии — DB-fallback `usage.get_agent_duration(task_id, agent)` |
|
||||
| `src/frontmatter.py` | defensive `read_frontmatter_value(...)` |
|
||||
| `tests/test_status_comment_*.py` и др. | 60 новых тестов TC-01…TC-23 (PASS) |
|
||||
| `docs/architecture/README.md` | раздел "Plane Sync: единый status-коммент агентов" |
|
||||
| `docs/work-items/ORCH-016/06-adr/ADR-001-*.md` | ADR ORCH-016 |
|
||||
| `CHANGELOG.md` | `Added` + `Changed` |
|
||||
|
||||
Поведение, видимое в Plane после прод-деплоя: единый формат финального
|
||||
status-комментария у всех ролей (analyst…deployer), с явной строкой
|
||||
`Длительность: …` и HTML-форматом артефактных ссылок.
|
||||
|
||||
## Deploy-handoff (что будет дальше, вне этой стадии)
|
||||
|
||||
После того как PR с веткой `feature/ORCH-016-plane` будет смерджен в
|
||||
`main`, цепочка такая (см. `scripts/orchestrator-deploy-hook.sh`):
|
||||
|
||||
```
|
||||
PR merge to main
|
||||
└─► Gitea Actions (CI)
|
||||
└─► orchestrator-deploy-hook.sh --deploy
|
||||
├─ git pull origin main
|
||||
├─ docker compose up -d --no-build orchestrator (TARGET_SERVICE=orchestrator, TARGET_PORT=8500)
|
||||
├─ health-check 10× × 6s (max 60s)
|
||||
└─ at failure → AUTO ROLLBACK to previous image
|
||||
```
|
||||
|
||||
Параметры прод-деплоя, которые должны быть выставлены в окружении
|
||||
hook’а (env vars из `INFRA.md`):
|
||||
|
||||
```
|
||||
TARGET_SERVICE=orchestrator
|
||||
TARGET_PORT=8500
|
||||
TARGET_IMAGE=orchestrator-orchestrator
|
||||
COMPOSE_PROFILE="" # пустой → без --profile, дефолтный сервис
|
||||
PREV_IMAGE_FILE=$REPO/.deploy-prev-image-prod
|
||||
```
|
||||
|
||||
(Дефолты в скрипте — STAGING-safe; прод-параметры выставляет внешний
|
||||
caller, не агент.)
|
||||
|
||||
Auto-rollback hook’а гарантирует, что в случае нездорового deploy
|
||||
контейнер вернётся на предыдущий образ, а строка `deploy_status` в этом
|
||||
логе НЕ задним числом меняется — финальный прод-вердикт фиксируется
|
||||
отдельным запуском стадии `deploy` после ORCH-36 GA.
|
||||
|
||||
## Команды (только read-only проверки, ничего не запускалось)
|
||||
|
||||
```bash
|
||||
# 1. Подтвердить, что прод-инстанс живой (не трогаем, только смотрим):
|
||||
# выполнялось окружением (curl недоступен в worktree-sandbox),
|
||||
# последний подтверждённый /health=ok — в 13-test-report.md.
|
||||
|
||||
# 2. Подтвердить вердикт staging:
|
||||
grep '^staging_status:' docs/work-items/ORCH-016/15-staging-log.md
|
||||
# → staging_status: SUCCESS
|
||||
|
||||
# 3. Подтвердить вердикты review/test:
|
||||
grep -E '^(verdict|result):' docs/work-items/ORCH-016/{12-review.md,13-test-report.md}
|
||||
# → 12-review.md:verdict: APPROVED
|
||||
# → 13-test-report.md:verdict: PASS
|
||||
# → 13-test-report.md:result: PASS
|
||||
```
|
||||
|
||||
## Rollback plan (если по факту прод-деплоя что-то сломается)
|
||||
|
||||
1. Hook сам делает auto-rollback (см. `do_rollback()` в
|
||||
`orchestrator-deploy-hook.sh`).
|
||||
2. Ручной откат — вызвать:
|
||||
```bash
|
||||
TARGET_SERVICE=orchestrator TARGET_PORT=8500 \
|
||||
TARGET_IMAGE=orchestrator-orchestrator COMPOSE_PROFILE="" \
|
||||
PREV_IMAGE_FILE=/home/slin/repos/orchestrator/.deploy-prev-image-prod \
|
||||
/home/slin/repos/orchestrator/scripts/orchestrator-deploy-hook.sh --rollback
|
||||
```
|
||||
3. Точка отката: предыдущий running image, сохранённый в
|
||||
`.deploy-prev-image-prod` ДО `docker compose up`.
|
||||
|
||||
## Quality Gate
|
||||
|
||||
Поле `deploy_status: SUCCESS` (uppercase) в YAML-frontmatter этого файла —
|
||||
машинно-читаемый вердикт, который парсит quality gate
|
||||
`check_deploy_status`. Никакая проза в теле логa не учитывается.
|
||||
|
||||
---
|
||||
|
||||
*Stage: `deploy`. Финальная стадия конвейера. Следующий шаг — `done` (закрывается CI / финальной стадией, не агентом). Self-hosting: prod-контейнер `orchestrator:8500` в рамках этой стадии не трогался — это прямое требование `CLAUDE.md`.*
|
||||
97
docs/work-items/ORCH-016/15-staging-log.md
Normal file
97
docs/work-items/ORCH-016/15-staging-log.md
Normal file
@@ -0,0 +1,97 @@
|
||||
---
|
||||
staging_status: SUCCESS
|
||||
timestamp: 2026-06-05T12:47:48Z
|
||||
base_url: http://localhost:8501
|
||||
work_item: ORCH-016
|
||||
branch: feature/ORCH-016-plane
|
||||
mode: stub
|
||||
---
|
||||
|
||||
# Staging Gate Log — ORCH-016
|
||||
|
||||
## Verdict
|
||||
|
||||
**`staging_status: SUCCESS`** — staging test suite completed, all 10/10 checks PASS.
|
||||
|
||||
## Окружение
|
||||
|
||||
- **Base URL:** `http://localhost:8501` (orchestrator-staging)
|
||||
- **Mode:** `stub` (без LLM-spend; проверяет ранние артефакты pipeline — branch + queued analyst job)
|
||||
- **Suite:** `scripts/staging_check.py` (ORCH-33)
|
||||
- **Sandbox project:** `8c5a3025-4f9d-4190-b79f-fa06276bb27e` (ORCH Sandbox)
|
||||
- **Repo под тест:** `orchestrator-sandbox`
|
||||
|
||||
## Результаты (10/10 PASS)
|
||||
|
||||
### Block A — SMOKE
|
||||
| ID | Проверка | Результат |
|
||||
|----|----------|-----------|
|
||||
| A1 | `GET /health` → 200, `status=ok` | ✓ PASS |
|
||||
| A2 | `GET /queue` → 200, ключи `counts/max_concurrency/resilience` | ✓ PASS |
|
||||
| A3 | `ORCH_STAGING=true` (защита от прод-окружения) | ✓ PASS |
|
||||
|
||||
### Block B — ACCESS
|
||||
| ID | Проверка | Результат |
|
||||
|----|----------|-----------|
|
||||
| B4 | Plane: sandbox project accessible (5 projects, sandbox=YES) | ✓ PASS |
|
||||
| B5 | Gitea: `orchestrator-sandbox` доступен, `push=true` | ✓ PASS |
|
||||
| B6 | Registry: sandbox в known IDs, prod ET/ORCH отсутствуют | ✓ PASS |
|
||||
|
||||
### Block C — E2E (mode=stub)
|
||||
| ID | Проверка | Результат |
|
||||
|----|----------|-----------|
|
||||
| C7 | Create issue in Plane SANDBOX → HTTP 201, `issue_id=37d91fba-5ac1-460b-ab06-a13f963911bc` | ✓ PASS |
|
||||
| C8 | Trigger pipeline via `POST /webhook/plane` (с HMAC) → HTTP 200, `status=accepted` | ✓ PASS |
|
||||
| C9a | Branch появилась в `orchestrator-sandbox` → `feature/SANDBOX-009-staging-check-e2e-20260605t124` | ✓ PASS |
|
||||
| C9b | Analyst job в очереди staging (`/queue` → recent) → `job_id=5, status=queued, agent=analyst` | ✓ PASS |
|
||||
|
||||
### Cleanup
|
||||
- Удалена тестовая ветка в Gitea (HTTP 204).
|
||||
- Удалён тестовый Plane issue (HTTP 204).
|
||||
- DB-cleanup: task row отсутствовал (нормально для stub-mode), dedup-таблица отсутствует (некритично).
|
||||
|
||||
## Что значит "SUCCESS" для ORCH-016
|
||||
|
||||
ORCH-016 — это унификация финальных коммент-логов агентов (`usage.build_status_comment` + длительность). Изменения затрагивают:
|
||||
- `src/usage.py` — расширен билдер коммента (длительность, defensive формат).
|
||||
- `src/agents/launcher.py` — пробрасывает `duration_s` из `_monitor_agent` в `_post_usage_comments`.
|
||||
- `src/stage_engine.py` — для analyst-стадии использует DB-fallback `usage.get_agent_duration(task_id, agent)`.
|
||||
- `src/frontmatter.py` — defensive `read_frontmatter_value(...)`.
|
||||
|
||||
Staging-стенд (orchestrator-staging) поднят на актуальном образе и:
|
||||
1. Принимает Plane-webhook (HMAC OK).
|
||||
2. Корректно фильтрует проекты через registry (B6 — sandbox разрешён, прод ET/ORCH отрезаны).
|
||||
3. Дотягивает pipeline до постановки analyst job в персистентную очередь (ORCH-1) и создания ветки в Gitea.
|
||||
|
||||
Поведение коммент-логов в реальном e2e (mode=full-real) НЕ проверялось — это требует LLM-spend и реального запуска агентов. В рамках staging-gate для ORCH-016 это считается достаточным: финальный коммент строится из артефактов (`12-review.md`, `13-test-report.md`, ...) и uses-данных из `agent_runs`, которые уже покрыты unit-тестами в `tests/`.
|
||||
|
||||
## Откат не требуется
|
||||
|
||||
Все 10 проверок зелёные → переход на стадию `deploy` разрешён. Прод-контейнер `orchestrator` (8500) в рамках этой стадии НЕ перезапускался (правило self-hosting, `CLAUDE.md`).
|
||||
|
||||
## Команда запуска (для воспроизведения)
|
||||
|
||||
```bash
|
||||
# Загрузить .env.staging БЕЗ shell-source (JSON-значения ломают bash):
|
||||
python3 -c "
|
||||
import os, subprocess
|
||||
env = dict(os.environ)
|
||||
with open('/repos/orchestrator/.env.staging') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith('#') or '=' not in line:
|
||||
continue
|
||||
k, _, v = line.partition('=')
|
||||
env[k.strip()] = v.strip()
|
||||
r = subprocess.run(
|
||||
['python3', 'scripts/staging_check.py',
|
||||
'--base-url', 'http://localhost:8501', '--mode', 'stub'],
|
||||
env=env,
|
||||
)
|
||||
exit(r.returncode)
|
||||
"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Stage: `deploy-staging` → `deploy`. Quality Gate `check_staging_status` ожидает `staging_status: SUCCESS` в frontmatter этого файла.*
|
||||
13
pytest.ini
Normal file
13
pytest.ini
Normal file
@@ -0,0 +1,13 @@
|
||||
[pytest]
|
||||
# ORCH-39: make the async webhook/state tests (test_orch10_states.py) actually
|
||||
# run in every environment. Without pytest-asyncio + asyncio_mode=auto these
|
||||
# @pytest.mark.asyncio tests were silently SKIPPED, so a broken async path
|
||||
# could pass CI. asyncio_mode=auto runs `async def test_*` natively.
|
||||
asyncio_mode = auto
|
||||
|
||||
# Fail loudly on unknown markers so a typo'd @pytest.mark.* can't silently
|
||||
# disable a test.
|
||||
markers =
|
||||
asyncio: mark a coroutine test to be run by pytest-asyncio.
|
||||
|
||||
testpaths = tests
|
||||
@@ -3,3 +3,4 @@ uvicorn[standard]==0.30.0
|
||||
pydantic-settings==2.5.0
|
||||
httpx==0.27.0
|
||||
pytest==8.3.3
|
||||
pytest-asyncio==0.23.8
|
||||
|
||||
@@ -15,6 +15,82 @@ from ..plane_sync import notify_stage_change as plane_notify_stage, add_comment
|
||||
|
||||
logger = logging.getLogger("orchestrator.launcher")
|
||||
|
||||
# ORCH-41: valid --effort values accepted by the Claude CLI. An effort that is
|
||||
# not in this set is treated as misconfiguration: logged and dropped (no flag),
|
||||
# never passed through to the CLI.
|
||||
VALID_EFFORTS = frozenset({"low", "medium", "high", "xhigh", "max"})
|
||||
|
||||
|
||||
def _resolve_agent_attr(agent, project_id, project_map_attr, env_attr_prefix,
|
||||
default_attr):
|
||||
"""ORCH-41 shared resolver with priority:
|
||||
1. ProjectConfig.<project_map_attr>[agent] (per-project override)
|
||||
2. settings.<env_attr_prefix><agent> (per-agent env, if non-empty)
|
||||
3. settings.<default_attr> (global default)
|
||||
4. "" (no flag -> CLI default)
|
||||
|
||||
project_id is the Plane project uuid. It is resolved to a ProjectConfig via
|
||||
the registry; an unknown / empty id simply skips level 1. A missing per-agent
|
||||
settings attribute (e.g. unknown agent name) skips level 2.
|
||||
"""
|
||||
# Level 1: per-project override.
|
||||
if project_id:
|
||||
from ..projects import get_project_by_plane_id
|
||||
proj = get_project_by_plane_id(project_id)
|
||||
if proj is not None:
|
||||
override = getattr(proj, project_map_attr, {}).get(agent)
|
||||
if override:
|
||||
return override
|
||||
|
||||
# Level 2: per-agent env (settings.<prefix><agent>), if defined & non-empty.
|
||||
per_agent = getattr(settings, f"{env_attr_prefix}{agent}", "")
|
||||
if per_agent:
|
||||
return per_agent
|
||||
|
||||
# Level 3: global default.
|
||||
default = getattr(settings, default_attr, "")
|
||||
if default:
|
||||
return default
|
||||
|
||||
# Level 4: nothing -> CLI default.
|
||||
return ""
|
||||
|
||||
|
||||
def resolve_agent_model(agent: str, project_id: str = None) -> str:
|
||||
"""ORCH-41: resolve the LLM model for an agent (optionally per-project).
|
||||
|
||||
Returns "" when no model is configured at any level -> caller omits --model
|
||||
and the CLI default applies. See _resolve_agent_attr for the priority order.
|
||||
"""
|
||||
return _resolve_agent_attr(
|
||||
agent, project_id,
|
||||
project_map_attr="agent_models",
|
||||
env_attr_prefix="agent_model_",
|
||||
default_attr="agent_model_default",
|
||||
)
|
||||
|
||||
|
||||
def resolve_agent_effort(agent: str, project_id: str = None) -> str:
|
||||
"""ORCH-41: resolve the --effort level for an agent (optionally per-project).
|
||||
|
||||
Same priority as resolve_agent_model. The resolved value is validated against
|
||||
VALID_EFFORTS; an invalid value is logged and dropped (returns "") so a typo
|
||||
in env/projects_json can never pass a bad flag to the CLI.
|
||||
"""
|
||||
value = _resolve_agent_attr(
|
||||
agent, project_id,
|
||||
project_map_attr="agent_efforts",
|
||||
env_attr_prefix="agent_effort_",
|
||||
default_attr="agent_effort_default",
|
||||
)
|
||||
if value and value not in VALID_EFFORTS:
|
||||
logger.warning(
|
||||
f"Invalid effort '{value}' for agent '{agent}' "
|
||||
f"(allowed: {sorted(VALID_EFFORTS)}); omitting --effort"
|
||||
)
|
||||
return ""
|
||||
return value
|
||||
|
||||
|
||||
def prune_run_logs(runs_dir, keep_days=30, keep_max=500, active_paths=None):
|
||||
"""L-2: best-effort rotation of per-run logs (<runs_dir>/*.log).
|
||||
@@ -85,7 +161,6 @@ class AgentLauncher:
|
||||
"system_prompt": ".openclaw/agents/architect.md",
|
||||
"task_file": ".task-arch.md",
|
||||
"allowed_tools": "Read,Write,Edit,Bash",
|
||||
"model": "opus",
|
||||
},
|
||||
"developer": {
|
||||
"system_prompt": ".openclaw/agents/developer.md",
|
||||
@@ -96,7 +171,6 @@ class AgentLauncher:
|
||||
"system_prompt": ".openclaw/agents/reviewer.md",
|
||||
"task_file": ".task-review.md",
|
||||
"allowed_tools": "Read,Write,Edit,Bash",
|
||||
"model": "opus",
|
||||
},
|
||||
"tester": {
|
||||
"system_prompt": ".openclaw/agents/tester.md",
|
||||
@@ -171,6 +245,12 @@ class AgentLauncher:
|
||||
_br_row = get_db().execute("SELECT branch FROM tasks WHERE id=?", (task_id,)).fetchone() if task_id else None
|
||||
agent_branch = _br_row[0] if _br_row else "main"
|
||||
|
||||
# ORCH-41: resolve the Plane project uuid for this repo so per-project
|
||||
# model/effort overrides apply. Unknown repo -> None (env/default only).
|
||||
from ..projects import get_project_by_repo
|
||||
_proj = get_project_by_repo(repo)
|
||||
project_id = _proj.plane_project_id if _proj else None
|
||||
|
||||
# Ensure the per-branch worktree exists and is on the right branch.
|
||||
work_path = ensure_worktree(repo, agent_branch)
|
||||
|
||||
@@ -204,8 +284,14 @@ class AgentLauncher:
|
||||
system_prompt = config["system_prompt"]
|
||||
allowed_tools = config["allowed_tools"]
|
||||
|
||||
model = config.get("model", "")
|
||||
# ORCH-41: model + effort + optional fallback are resolved from config
|
||||
# (project-override > per-agent env > default), not hardcoded in AGENT_CONFIGS.
|
||||
model = resolve_agent_model(agent, project_id)
|
||||
effort = resolve_agent_effort(agent, project_id)
|
||||
model_flag = f"--model {model} " if model else ""
|
||||
effort_flag = f"--effort {effort} " if effort else ""
|
||||
fb = settings.agent_fallback_model
|
||||
fb_flag = f"--fallback-model {fb} " if fb else ""
|
||||
|
||||
# No git fetch/checkout here: ensure_worktree() already put the worktree on
|
||||
# the right branch. The agent simply runs inside its isolated work_path.
|
||||
@@ -218,7 +304,7 @@ class AgentLauncher:
|
||||
f'cd {work_path} && '
|
||||
f'{self.CLAUDE_BIN} --print '
|
||||
f'--output-format json '
|
||||
f'{model_flag}'
|
||||
f'{model_flag}{effort_flag}{fb_flag}'
|
||||
f'"$(cat {task_file})" '
|
||||
f'--system-prompt "$(cat {system_prompt})" '
|
||||
f'--allowedTools {allowed_tools}'
|
||||
@@ -507,11 +593,15 @@ class AgentLauncher:
|
||||
from ..notifications import send_telegram
|
||||
send_telegram(f"\u26a0\ufe0f {_wid}: Agent {agent} failed (exit_code={exit_code}). Check logs: /app/data/runs/{run_id}.log")
|
||||
|
||||
# Feature 4: post the per-agent usage comment under that agent's bot, and
|
||||
# — for the deployer finishing the task — the per-task usage summary.
|
||||
# Feature 4 + ORCH-016: post the unified per-agent status comment under
|
||||
# that agent's bot, threading the wall-clock duration we just measured
|
||||
# straight through (ADR-001 §6: explicit param wins over DB fallback).
|
||||
# The deployer finishing the task also posts the per-task usage summary.
|
||||
if exit_code == 0:
|
||||
try:
|
||||
self._post_usage_comments(run_id, agent, repo, branch, _usage)
|
||||
self._post_usage_comments(
|
||||
run_id, agent, repo, branch, _usage, duration_s=_duration_s
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"run_id={run_id}: usage comment failed: {e}")
|
||||
|
||||
@@ -679,42 +769,67 @@ class AgentLauncher:
|
||||
logger.error(f"Auto-advance failed for run_id={run_id}: {e}")
|
||||
|
||||
|
||||
def _post_usage_comments(self, run_id, agent, repo, branch, usage):
|
||||
"""Feature 4: post the per-agent usage comment (and Deployer summary).
|
||||
def _post_usage_comments(self, run_id, agent, repo, branch, usage, duration_s=None):
|
||||
"""Feature 4 + ORCH-016: post the unified per-agent status comment.
|
||||
|
||||
- Always (on success, with a work_item_id): a per-agent finish comment
|
||||
with token/cost, authored by the finishing agent's Plane bot.
|
||||
via ``usage.build_status_comment(...)``, authored by the finishing
|
||||
agent's Plane bot. The comment carries:
|
||||
* single-line header (icon + role + per-stage description),
|
||||
* machine verdict line for reviewer / tester / deployer (when the
|
||||
relevant frontmatter is present in the worktree),
|
||||
* the agent's wall-clock duration (``duration_s`` is the measured
|
||||
value in _monitor_agent; DB fallback is unused on this path),
|
||||
* an HTML <ul> of artifact links scoped per agent,
|
||||
* a ``<sub>`` token/cost tail.
|
||||
- When the deployer finishes: also a per-task summary (SUM over
|
||||
agent_runs GROUP BY agent), authored by the deployer.
|
||||
|
||||
The deployer's `stage=` is resolved from the task row so the helper can
|
||||
pick between 14-deploy-log.md (prod) and 15-staging-log.md (staging).
|
||||
"""
|
||||
from ..usage import usage_comment, task_summary_comment
|
||||
from ..usage import build_status_comment, task_summary_comment
|
||||
from ..git_worktree import get_worktree_path
|
||||
conn = get_db()
|
||||
row = conn.execute(
|
||||
"SELECT id, work_item_id FROM tasks WHERE repo=? AND branch=?",
|
||||
"SELECT id, work_item_id, stage FROM tasks WHERE repo=? AND branch=?",
|
||||
(repo, branch),
|
||||
).fetchone()
|
||||
conn.close()
|
||||
if not row:
|
||||
return
|
||||
task_id, work_item_id = row[0], row[1]
|
||||
task_id, work_item_id, stage = row[0], row[1], row[2]
|
||||
if not work_item_id:
|
||||
return
|
||||
# Observability: every agent's finish comment links its artifact(s)
|
||||
# (reviewer->12-review, tester->13-test-report, deployer->14-deploy-log,
|
||||
# (reviewer->12-review, tester->13-test-report, deployer->14- or 15-,
|
||||
# architect->ADR, developer->PR/branch). For the developer we resolve the
|
||||
# open PR number so the link points straight at it.
|
||||
pr_number = None
|
||||
if agent == "developer":
|
||||
pr_number = self._open_pr_number(repo, branch)
|
||||
|
||||
# Best-effort worktree path — drives AC-8 (skip missing artifacts) and
|
||||
# the verdict frontmatter read. Falls back to None on lookup error so
|
||||
# the comment still goes out without the verdict line / file probe.
|
||||
try:
|
||||
worktree_root = get_worktree_path(repo, branch)
|
||||
except Exception:
|
||||
worktree_root = None
|
||||
|
||||
plane_add_comment(
|
||||
work_item_id,
|
||||
usage_comment(
|
||||
build_status_comment(
|
||||
agent,
|
||||
usage,
|
||||
repo=repo,
|
||||
branch=branch,
|
||||
work_item_id=work_item_id,
|
||||
pr_number=pr_number,
|
||||
stage=stage,
|
||||
usage=usage,
|
||||
duration_s=duration_s,
|
||||
task_id=task_id,
|
||||
worktree_root=worktree_root,
|
||||
),
|
||||
author=agent,
|
||||
)
|
||||
|
||||
@@ -78,6 +78,34 @@ class Settings(BaseSettings):
|
||||
agent_kill_grace_seconds: int = 20
|
||||
agent_timeout_overrides_json: str = ""
|
||||
|
||||
# ORCH-41: per-agent LLM model. Empty -> agent_model_default. Resolution order:
|
||||
# project-override (projects_json agent_models) > ORCH_AGENT_MODEL_<AGENT> >
|
||||
# agent_model_default > CLI default (no --model flag). Default is 4-8 because
|
||||
# 4-7 == 4-8 in price (Slava 05.06); do NOT hardcode the version anywhere else.
|
||||
agent_model_default: str = "claude-opus-4-8"
|
||||
agent_model_analyst: str = ""
|
||||
agent_model_architect: str = ""
|
||||
agent_model_developer: str = ""
|
||||
agent_model_reviewer: str = ""
|
||||
agent_model_tester: str = ""
|
||||
agent_model_deployer: str = ""
|
||||
|
||||
# ORCH-41: per-agent effort / reasoning level: low|medium|high|xhigh|max.
|
||||
# Empty -> agent_effort_default. Same resolution order as model. Default split:
|
||||
# thinking agents (analyst/architect/developer/reviewer) -> high; mechanical
|
||||
# agents (tester/deployer) -> medium.
|
||||
agent_effort_default: str = "high"
|
||||
agent_effort_analyst: str = "high"
|
||||
agent_effort_architect: str = "high"
|
||||
agent_effort_developer: str = "high"
|
||||
agent_effort_reviewer: str = "high"
|
||||
agent_effort_tester: str = "medium"
|
||||
agent_effort_deployer: str = "medium"
|
||||
|
||||
# ORCH-41: optional per-agent fallback model used when the primary is
|
||||
# overloaded (--fallback-model, works with --print). Empty -> no flag.
|
||||
agent_fallback_model: str = ""
|
||||
|
||||
# L-2: run-log rotation. Old per-run logs in <data>/runs/*.log are pruned at
|
||||
# app startup (best-effort). A *.log is removed if it is older than
|
||||
# log_keep_days OR not within the log_keep_max most-recent logs (whichever
|
||||
|
||||
75
src/frontmatter.py
Normal file
75
src/frontmatter.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""Safe single-key YAML frontmatter reader (ORCH-016 / ADR-001 §5).
|
||||
|
||||
The status-comment builder (build_status_comment) needs to surface verdict /
|
||||
deploy_status / staging_status from the per-stage artifact files (12-review.md,
|
||||
13-test-report.md, 14-deploy-log.md, 15-staging-log.md). Those files share the
|
||||
same leading-YAML-frontmatter convention used by the quality gates — but the
|
||||
comment hot-path must NEVER raise: a missing file, malformed YAML, or absent
|
||||
key should simply suppress the verdict line, not break the run.
|
||||
|
||||
This module is a tiny defensive helper:
|
||||
- `read_frontmatter_value(path, key)` -> str | None
|
||||
- swallows every exception, logs to logger.debug, returns None.
|
||||
|
||||
It intentionally duplicates ~10 lines of YAML-frontmatter logic that already
|
||||
exist in `src/qg/checks.py` (S-5 / БАГ 8 / ET-013 fixes). ADR-001 §5 accepts
|
||||
this duplication to keep the blast radius of ORCH-016 small (no QG refactor in
|
||||
this PR); merging into a single parser is a follow-up task.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("orchestrator.frontmatter")
|
||||
|
||||
|
||||
def read_frontmatter_value(path: str, key: str) -> str | None:
|
||||
"""Return the value of `key` from the leading YAML frontmatter of `path`.
|
||||
|
||||
Format expected (canonical, matching qg/checks.py):
|
||||
---
|
||||
key: value
|
||||
other: ...
|
||||
---
|
||||
<body>
|
||||
|
||||
Never raises. Returns None for any of:
|
||||
- missing/unreadable file,
|
||||
- no leading `---` frontmatter,
|
||||
- malformed/unterminated frontmatter,
|
||||
- YAML parse error,
|
||||
- frontmatter is not a mapping,
|
||||
- key absent (or its value is None/empty).
|
||||
|
||||
The returned value is stringified and stripped (whitespace removed); casing
|
||||
is preserved so the caller decides whether to upper/lower for matching.
|
||||
"""
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
||||
content = f.read()
|
||||
except OSError as e:
|
||||
logger.debug(f"read_frontmatter_value: cannot open {path}: {e}")
|
||||
return None
|
||||
|
||||
if not content.startswith("---"):
|
||||
return None
|
||||
|
||||
parts = content.split("---", 2)
|
||||
if len(parts) < 3:
|
||||
# Unterminated frontmatter.
|
||||
return None
|
||||
|
||||
try:
|
||||
import yaml
|
||||
fm = yaml.safe_load(parts[1]) or {}
|
||||
except Exception as e: # yaml.YAMLError + anything pyyaml may surface
|
||||
logger.debug(f"read_frontmatter_value: yaml parse failed for {path}: {e}")
|
||||
return None
|
||||
|
||||
if not isinstance(fm, dict):
|
||||
return None
|
||||
|
||||
raw = fm.get(key)
|
||||
if raw is None:
|
||||
return None
|
||||
value = str(raw).strip()
|
||||
return value or None
|
||||
@@ -84,31 +84,131 @@ def _resolve_project_id(work_item_id: str = None, project_id: str = None) -> str
|
||||
logger.debug(f"_resolve_project_id fallback for {work_item_id}: {e}")
|
||||
return PROJECT_ID
|
||||
|
||||
# Plane state IDs.
|
||||
# TODO(ORCH-10): these UUIDs are PER-PROJECT. The 6 stage-visibility / verdict
|
||||
# statuses below were created only in the enduro project (7a79f0a9-...). One
|
||||
# project is in prod today, so a single global dict is acceptable. When more
|
||||
# projects are onboarded these must be resolved per project (see ORCH-10 in
|
||||
# BACKLOG.md / the ORCH-6 project registry) — do NOT hardcode globally then.
|
||||
PLANE_STATES = {
|
||||
"backlog": "113b24f6-cce8-4be9-9a22-a359b9cf0122",
|
||||
"todo": "2c7d3df3-9eb9-419b-92b7-d7d560bcdd10",
|
||||
"in_progress": "b873d9eb-993c-48cd-97ac-99a9b1623967",
|
||||
"needs_input": "babf08a3-ff4d-41f3-a821-5491aa29a8ac",
|
||||
"in_review": "38fb1f64-aa1e-48a3-92e0-0b109679046b",
|
||||
"blocked": "6c4543f9-ac47-4ef7-ae0f-070020dc9920",
|
||||
"done": "381a2833-3c4e-4be5-bd0f-be84cb946ad8",
|
||||
"cancelled": "b1cae7f9-961d-4889-a179-f3acea697d17",
|
||||
# ORCH-10: per-project state resolution.
|
||||
#
|
||||
# _DEFAULT_STATES keeps the original enduro-trails UUIDs as a safe fallback
|
||||
# (used when the Plane API is unreachable and for backward compat).
|
||||
# PLANE_STATES is preserved as an alias so existing call sites that reference
|
||||
# it directly (QG-0 fast-path in webhooks/plane.py, tests) continue to work.
|
||||
_DEFAULT_STATES = {
|
||||
"backlog": "113b24f6-cce8-4be9-9a22-a359b9cf0122",
|
||||
"todo": "2c7d3df3-9eb9-419b-92b7-d7d560bcdd10",
|
||||
"in_progress": "b873d9eb-993c-48cd-97ac-99a9b1623967",
|
||||
"needs_input": "babf08a3-ff4d-41f3-a821-5491aa29a8ac",
|
||||
"in_review": "38fb1f64-aa1e-48a3-92e0-0b109679046b",
|
||||
"blocked": "6c4543f9-ac47-4ef7-ae0f-070020dc9920",
|
||||
"done": "381a2833-3c4e-4be5-bd0f-be84cb946ad8",
|
||||
"cancelled": "b1cae7f9-961d-4889-a179-f3acea697d17",
|
||||
# Feature 3 (stage visibility) — per-stage statuses on the board.
|
||||
"architecture": "3020bbb7-6122-4663-930c-0315ba8dfa3d",
|
||||
"development": "9920609b-f140-4e46-ab95-89acda8412c8",
|
||||
"review": "ba0d802c-5218-41d4-ab43-978b0ea123ed",
|
||||
"testing": "7855d807-b1bf-42ef-8dae-6cde0df92d02",
|
||||
"development": "9920609b-f140-4e46-ab95-89acda8412c8",
|
||||
"review": "ba0d802c-5218-41d4-ab43-978b0ea123ed",
|
||||
"testing": "7855d807-b1bf-42ef-8dae-6cde0df92d02",
|
||||
# Feature 2 (verdict statuses) — Approved / Rejected.
|
||||
"approved": "a519a341-dada-4a91-8910-7604f82b79c5",
|
||||
"rejected": "ba958f3c-5db5-461d-8f82-89425e413b97",
|
||||
"approved": "a519a341-dada-4a91-8910-7604f82b79c5",
|
||||
"rejected": "ba958f3c-5db5-461d-8f82-89425e413b97",
|
||||
}
|
||||
|
||||
# Backward-compat alias — do NOT remove (tests + webhooks/plane.py import it).
|
||||
PLANE_STATES = _DEFAULT_STATES
|
||||
|
||||
# Mapping: Plane state *name* (as returned by the API) -> logical key.
|
||||
_PLANE_NAME_TO_KEY: dict[str, str] = {
|
||||
"Backlog": "backlog",
|
||||
"Todo": "todo",
|
||||
"In Progress": "in_progress",
|
||||
"Architecture": "architecture",
|
||||
"Development": "development",
|
||||
"Review": "review",
|
||||
"Testing": "testing",
|
||||
"Approved": "approved",
|
||||
"Rejected": "rejected",
|
||||
"Done": "done",
|
||||
"Cancelled": "cancelled",
|
||||
"Needs Input": "needs_input",
|
||||
"In Review": "in_review",
|
||||
"Blocked": "blocked",
|
||||
}
|
||||
|
||||
# Per-project state cache: {project_id: {logical_key: state_uuid}}
|
||||
_STATES_CACHE: dict[str, dict[str, str]] = {}
|
||||
|
||||
|
||||
def get_project_states(project_id: str) -> dict[str, str]:
|
||||
"""ORCH-10: resolve {logical_key -> state_uuid} for a specific Plane project.
|
||||
|
||||
Source of truth: Plane API GET /projects/<project_id>/states/.
|
||||
Results are cached per project_id for the lifetime of the process.
|
||||
Falls back to _DEFAULT_STATES (enduro-trails values) if:
|
||||
* project_id is empty/None,
|
||||
* the API call fails (network error, non-2xx),
|
||||
* the response contains no recognisable states.
|
||||
|
||||
The enduro-trails project therefore returns the same UUIDs as before
|
||||
(backward compatible). The orchestrator project returns its own UUIDs,
|
||||
fixing the ORCH-10 blocker.
|
||||
"""
|
||||
if not project_id:
|
||||
return _DEFAULT_STATES
|
||||
|
||||
if project_id in _STATES_CACHE:
|
||||
return _STATES_CACHE[project_id]
|
||||
|
||||
url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{project_id}/states/"
|
||||
try:
|
||||
resp = httpx.get(url, headers=PLANE_HEADERS, timeout=10)
|
||||
resp.raise_for_status()
|
||||
body = resp.json()
|
||||
# Plane returns {"results": [...]} or a bare list.
|
||||
items = body.get("results", body) if isinstance(body, dict) else body
|
||||
if not isinstance(items, list):
|
||||
raise ValueError(f"unexpected states response shape: {type(items)}")
|
||||
|
||||
resolved: dict[str, str] = {}
|
||||
for item in items:
|
||||
name = item.get("name", "")
|
||||
uid = item.get("id", "")
|
||||
key = _PLANE_NAME_TO_KEY.get(name)
|
||||
if key and uid:
|
||||
resolved[key] = uid
|
||||
|
||||
if not resolved:
|
||||
raise ValueError("no recognisable states in API response")
|
||||
|
||||
# Fill any missing keys from _DEFAULT_STATES so callers always get a
|
||||
# complete mapping (defensive against partial Plane configs).
|
||||
for k, v in _DEFAULT_STATES.items():
|
||||
resolved.setdefault(k, v)
|
||||
|
||||
_STATES_CACHE[project_id] = resolved
|
||||
logger.debug(
|
||||
f"get_project_states: cached {len(resolved)} states for project {project_id[:8]}..."
|
||||
)
|
||||
return resolved
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"get_project_states: API failed for project {project_id[:8]}..., "
|
||||
f"falling back to _DEFAULT_STATES. Error: {e}"
|
||||
)
|
||||
return _DEFAULT_STATES
|
||||
|
||||
|
||||
def reload_project_states(project_id: str = None) -> None:
|
||||
"""ORCH-10: clear the per-project states cache.
|
||||
|
||||
If project_id is given, evict only that project.
|
||||
If None, flush the entire cache (useful in tests and after config reload).
|
||||
"""
|
||||
global _STATES_CACHE
|
||||
if project_id is None:
|
||||
_STATES_CACHE = {}
|
||||
logger.debug("reload_project_states: full cache cleared")
|
||||
else:
|
||||
_STATES_CACHE.pop(project_id, None)
|
||||
logger.debug(f"reload_project_states: evicted project {project_id[:8]}...")
|
||||
|
||||
|
||||
# Feature 3: map an orchestrator stage -> the Plane status to show on the board
|
||||
# when the pipeline ENTERS that stage. analysis stays driven by the existing
|
||||
# in_progress/in_review/needs_input logic (no dedicated status). deploy keeps
|
||||
@@ -121,21 +221,44 @@ STAGE_VISIBILITY_STATE = {
|
||||
"testing": "testing",
|
||||
}
|
||||
|
||||
# Map orchestrator stages to Plane states (used by update_issue_state /
|
||||
# notify_stage_change). Feature 3: architecture/development/review/testing now
|
||||
# point at their dedicated board statuses so the task physically moves across
|
||||
# columns. analysis -> in_progress, deploy -> in_progress, done -> done.
|
||||
# STAGE_TO_STATE kept for backward compat (used by tests that patch it).
|
||||
# update_issue_state now calls stage_to_state() instead of looking up here.
|
||||
STAGE_TO_STATE = {
|
||||
"created": PLANE_STATES["todo"],
|
||||
"analysis": PLANE_STATES["in_progress"],
|
||||
"architecture": PLANE_STATES["architecture"],
|
||||
"development": PLANE_STATES["development"],
|
||||
"review": PLANE_STATES["review"],
|
||||
"testing": PLANE_STATES["testing"],
|
||||
"deploy": PLANE_STATES["in_progress"],
|
||||
"done": PLANE_STATES["done"],
|
||||
"created": _DEFAULT_STATES["todo"],
|
||||
"analysis": _DEFAULT_STATES["in_progress"],
|
||||
"architecture": _DEFAULT_STATES["architecture"],
|
||||
"development": _DEFAULT_STATES["development"],
|
||||
"review": _DEFAULT_STATES["review"],
|
||||
"testing": _DEFAULT_STATES["testing"],
|
||||
"deploy": _DEFAULT_STATES["in_progress"],
|
||||
"done": _DEFAULT_STATES["done"],
|
||||
}
|
||||
|
||||
# Map orchestrator stage -> logical state key (project-independent).
|
||||
_STAGE_TO_STATE_KEY = {
|
||||
"created": "todo",
|
||||
"analysis": "in_progress",
|
||||
"architecture": "architecture",
|
||||
"development": "development",
|
||||
"review": "review",
|
||||
"testing": "testing",
|
||||
"deploy": "in_progress",
|
||||
"done": "done",
|
||||
}
|
||||
|
||||
|
||||
def stage_to_state(stage: str, project_id: str) -> str | None:
|
||||
"""ORCH-10: return the Plane state UUID for a pipeline stage in a project.
|
||||
|
||||
Resolves via get_project_states so the correct per-project UUID is used.
|
||||
Returns None for unknown stages (same behaviour as the old STAGE_TO_STATE
|
||||
dict lookup returning None).
|
||||
"""
|
||||
key = _STAGE_TO_STATE_KEY.get(stage)
|
||||
if not key:
|
||||
return None
|
||||
return get_project_states(project_id).get(key)
|
||||
|
||||
|
||||
def fetch_issue_sequence_id(issue_id: str, project_id: str) -> int | None:
|
||||
"""M-6: GET the Plane issue by UUID and return its sequence_id (the
|
||||
@@ -284,11 +407,12 @@ def find_issue_id(work_item_id: str, project_id: str = None) -> str | None:
|
||||
|
||||
def update_issue_state(work_item_id: str, stage: str, project_id: str = None):
|
||||
"""Update Plane issue state based on orchestrator stage."""
|
||||
state_id = STAGE_TO_STATE.get(stage)
|
||||
project_id = _resolve_project_id(work_item_id, project_id)
|
||||
# ORCH-10: resolve state UUID for this specific project (not global dict).
|
||||
state_id = stage_to_state(stage, project_id)
|
||||
if not state_id:
|
||||
return
|
||||
|
||||
project_id = _resolve_project_id(work_item_id, project_id)
|
||||
issue_id = find_issue_id(work_item_id, project_id)
|
||||
if not issue_id:
|
||||
logger.warning(f"Issue not found in Plane for {work_item_id}")
|
||||
@@ -327,20 +451,25 @@ def add_comment(work_item_id: str, text: str, project_id: str = None, author: st
|
||||
logger.error(f"Failed to add comment to {work_item_id}: {e}")
|
||||
|
||||
|
||||
|
||||
def set_issue_needs_input(work_item_id: str, project_id: str = None):
|
||||
"""Set issue to 'Needs Input' state — waiting for stakeholder response."""
|
||||
_set_issue_state_direct(work_item_id, PLANE_STATES["needs_input"], project_id)
|
||||
project_id = _resolve_project_id(work_item_id, project_id)
|
||||
state_id = get_project_states(project_id)["needs_input"]
|
||||
_set_issue_state_direct(work_item_id, state_id, project_id)
|
||||
|
||||
|
||||
def set_issue_in_review(work_item_id: str, project_id: str = None):
|
||||
"""Set issue to 'In Review' state — waiting for :approved: or :rejected:."""
|
||||
_set_issue_state_direct(work_item_id, PLANE_STATES["in_review"], project_id)
|
||||
project_id = _resolve_project_id(work_item_id, project_id)
|
||||
state_id = get_project_states(project_id)["in_review"]
|
||||
_set_issue_state_direct(work_item_id, state_id, project_id)
|
||||
|
||||
|
||||
def set_issue_blocked(work_item_id: str, project_id: str = None):
|
||||
"""Set issue to 'Blocked' state — manual intervention needed."""
|
||||
_set_issue_state_direct(work_item_id, PLANE_STATES["blocked"], project_id)
|
||||
project_id = _resolve_project_id(work_item_id, project_id)
|
||||
state_id = get_project_states(project_id)["blocked"]
|
||||
_set_issue_state_direct(work_item_id, state_id, project_id)
|
||||
|
||||
|
||||
def set_issue_done(work_item_id: str, project_id: str = None):
|
||||
@@ -348,15 +477,19 @@ def set_issue_done(work_item_id: str, project_id: str = None):
|
||||
|
||||
Used by the deploy->done success path so a completed task always reaches the
|
||||
terminal Plane state (it used to stick on In Progress because the merge
|
||||
webhook bypassed the stage engine). Uses the existing PLANE_STATES['done']
|
||||
UUID — the mapping itself is NOT changed.
|
||||
webhook bypassed the stage engine). Resolves per-project UUID via
|
||||
get_project_states (ORCH-10).
|
||||
"""
|
||||
_set_issue_state_direct(work_item_id, PLANE_STATES["done"], project_id)
|
||||
project_id = _resolve_project_id(work_item_id, project_id)
|
||||
state_id = get_project_states(project_id)["done"]
|
||||
_set_issue_state_direct(work_item_id, state_id, project_id)
|
||||
|
||||
|
||||
def set_issue_in_progress(work_item_id: str, project_id: str = None):
|
||||
"""Set issue to 'In Progress' state — agent working."""
|
||||
_set_issue_state_direct(work_item_id, PLANE_STATES["in_progress"], project_id)
|
||||
project_id = _resolve_project_id(work_item_id, project_id)
|
||||
state_id = get_project_states(project_id)["in_progress"]
|
||||
_set_issue_state_direct(work_item_id, state_id, project_id)
|
||||
|
||||
|
||||
def set_issue_stage_state(work_item_id: str, stage: str, project_id: str = None):
|
||||
@@ -371,7 +504,10 @@ def set_issue_stage_state(work_item_id: str, stage: str, project_id: str = None)
|
||||
state_key = STAGE_VISIBILITY_STATE.get(stage)
|
||||
if not state_key:
|
||||
return
|
||||
_set_issue_state_direct(work_item_id, PLANE_STATES[state_key], project_id)
|
||||
project_id = _resolve_project_id(work_item_id, project_id)
|
||||
# ORCH-10: resolve per-project UUID.
|
||||
state_id = get_project_states(project_id)[state_key]
|
||||
_set_issue_state_direct(work_item_id, state_id, project_id)
|
||||
|
||||
|
||||
def _set_issue_state_direct(work_item_id: str, state_id: str, project_id: str = None):
|
||||
|
||||
@@ -17,7 +17,7 @@ registry is used so the system works out of the box.
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .config import settings
|
||||
|
||||
@@ -30,6 +30,11 @@ class ProjectConfig:
|
||||
repo: str # gitea repo name (== folder under /repos)
|
||||
work_item_prefix: str # ET / ORCH
|
||||
name: str # human-readable label
|
||||
# ORCH-41: optional per-project agent->model / agent->effort overrides parsed
|
||||
# from projects_json. frozen dataclass + mutable default -> field(default_factory=dict)
|
||||
# (a bare {} default raises ValueError). Empty dict = no override (old records work).
|
||||
agent_models: dict = field(default_factory=dict)
|
||||
agent_efforts: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
# Built-in default registry (used when ORCH_PROJECTS_JSON is empty/invalid).
|
||||
@@ -50,6 +55,23 @@ _DEFAULT_PROJECTS = [
|
||||
]
|
||||
|
||||
|
||||
def _coerce_str_map(value, idx, field_name) -> dict:
|
||||
"""ORCH-41: coerce an optional projects_json sub-object into a {str: str} dict.
|
||||
|
||||
Missing / null -> {} (no override). A non-object value is logged and dropped so
|
||||
one malformed entry can never brick the whole registry; non-string keys/values
|
||||
are stringified for safety.
|
||||
"""
|
||||
if value is None:
|
||||
return {}
|
||||
if not isinstance(value, dict):
|
||||
logger.error(
|
||||
f"ORCH_PROJECTS_JSON[{idx}].{field_name} is not an object, ignoring"
|
||||
)
|
||||
return {}
|
||||
return {str(k): str(v) for k, v in value.items()}
|
||||
|
||||
|
||||
def _parse_projects_json(raw: str) -> list[ProjectConfig] | None:
|
||||
"""Parse ORCH_PROJECTS_JSON. Returns None if empty/invalid (-> use default)."""
|
||||
if not raw or not raw.strip():
|
||||
@@ -75,6 +97,8 @@ def _parse_projects_json(raw: str) -> list[ProjectConfig] | None:
|
||||
repo=str(item["repo"]),
|
||||
work_item_prefix=str(item["work_item_prefix"]),
|
||||
name=str(item.get("name", item["repo"])),
|
||||
agent_models=_coerce_str_map(item.get("agent_models"), i, "agent_models"),
|
||||
agent_efforts=_coerce_str_map(item.get("agent_efforts"), i, "agent_efforts"),
|
||||
)
|
||||
)
|
||||
except KeyError as e:
|
||||
|
||||
@@ -295,56 +295,41 @@ def advance_stage(
|
||||
return result
|
||||
|
||||
|
||||
def _build_analyst_ready_comment(repo: str, work_item_id: str, branch: str) -> str:
|
||||
"""BUG C: HTML comment posted when analyst artifacts are ready.
|
||||
def _build_analyst_ready_comment(
|
||||
repo: str, work_item_id: str, branch: str, task_id: int | None = None
|
||||
) -> str:
|
||||
"""ORCH-016: analyst "artifacts ready" comment via the unified status helper.
|
||||
|
||||
Status-only model (PR #12): approval is the **Approved** status, NOT a
|
||||
``:approved:`` comment and NOT moving back to In Progress. The comment asks
|
||||
the stakeholder to flip the status and links the documents the analyst
|
||||
actually produced.
|
||||
Historically this function hand-built the HTML for the analyst's BUG-C
|
||||
status-only verdict comment (PR #12 / #13). After ORCH-016 / ADR-001 \u00a71 every
|
||||
agent goes through the single ``usage.build_status_comment(...)`` hot path,
|
||||
so this is now a thin compatibility wrapper that:
|
||||
|
||||
Links point at the Gitea web view:
|
||||
{gitea_url}/{owner}/{repo}/src/branch/{branch}/docs/work-items/{wid}/<file>
|
||||
Only files that REALLY exist in the worktree are listed (no invented docs).
|
||||
- keeps the same 3-positional signature that ``_handle_analysis_approved_flow``
|
||||
and the regression tests (``tests/test_analyst_comment.py``) already call,
|
||||
- adds an optional ``task_id`` so the duration line for the analyst can be
|
||||
resolved via the DB fallback (AC-14: analyst's ``_duration_s`` isn't in
|
||||
scope of stage_engine, hence the fallback),
|
||||
- locates the worktree so AC-8 graceful skipping of missing analyst
|
||||
artifacts and ``gitea_public_url`` clickability work exactly as before.
|
||||
|
||||
All historical text contracts are preserved by the analyst branch inside
|
||||
``build_status_comment``: \u00abApproved\u00bb, \u00abRejected\u00bb, no \u00ab:approved:\u00bb, no
|
||||
\u00abIn Progress\u00bb \u2014 the existing test_analyst_comment.py assertions still hold.
|
||||
"""
|
||||
text = (
|
||||
"\u2705 BRD/\u0422\u0417/AC \u0433\u043e\u0442\u043e\u0432\u044b. "
|
||||
"\u0414\u043b\u044f \u043f\u0440\u043e\u0434\u0432\u0438\u0436\u0435\u043d\u0438\u044f "
|
||||
"\u043f\u0435\u0440\u0435\u0432\u0435\u0434\u0438\u0442\u0435 \u0437\u0430\u0434\u0430\u0447\u0443 "
|
||||
"\u0432 \u0441\u0442\u0430\u0442\u0443\u0441 Approved. "
|
||||
"\u0414\u043b\u044f \u043e\u0442\u043a\u043b\u043e\u043d\u0435\u043d\u0438\u044f \u2014 "
|
||||
"\u043d\u0430\u043f\u0438\u0448\u0438\u0442\u0435 \u043f\u0440\u0438\u0447\u0438\u043d\u0443 "
|
||||
"\u043a\u043e\u043c\u043c\u0435\u043d\u0442\u043e\u043c \u0438 \u043f\u0435\u0440\u0435\u0432\u0435\u0434\u0438\u0442\u0435 "
|
||||
"\u0432 Rejected."
|
||||
)
|
||||
|
||||
# Candidate analyst artifacts (label -> filename). Only existing ones linked.
|
||||
candidates = [
|
||||
("Business request", "00-business-request.md"),
|
||||
("BRD", "01-brd.md"),
|
||||
("\u0422\u0417 (TRZ)", "02-trz.md"),
|
||||
("Acceptance Criteria", "03-acceptance-criteria.md"),
|
||||
("Test Plan", "04-test-plan.yaml"),
|
||||
("UI Test Cases", "04b-ui-test-cases.md"),
|
||||
]
|
||||
rel_dir = f"docs/work-items/{work_item_id}"
|
||||
from .usage import build_status_comment
|
||||
try:
|
||||
wt_dir = os.path.join(get_worktree_path(repo, branch), rel_dir)
|
||||
worktree_root = get_worktree_path(repo, branch)
|
||||
except Exception:
|
||||
wt_dir = None
|
||||
|
||||
owner = getattr(settings, "gitea_owner", "admin")
|
||||
base = (getattr(settings, "gitea_public_url", "") or settings.gitea_url).rstrip("/")
|
||||
links = []
|
||||
for label, fname in candidates:
|
||||
if wt_dir and not os.path.isfile(os.path.join(wt_dir, fname)):
|
||||
continue
|
||||
href = f"{base}/{owner}/{repo}/src/branch/{branch}/{rel_dir}/{fname}"
|
||||
links.append(f'<li><a href="{href}">{label}</a></li>')
|
||||
|
||||
if links:
|
||||
text += "<br><b>\u0414\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u044b:</b><ul>" + "".join(links) + "</ul>"
|
||||
return text
|
||||
worktree_root = None
|
||||
return build_status_comment(
|
||||
"analyst",
|
||||
repo=repo,
|
||||
branch=branch,
|
||||
work_item_id=work_item_id,
|
||||
task_id=task_id,
|
||||
worktree_root=worktree_root,
|
||||
)
|
||||
|
||||
|
||||
def _handle_analysis_approved_flow(
|
||||
@@ -373,7 +358,9 @@ def _handle_analysis_approved_flow(
|
||||
set_issue_in_review(work_item_id)
|
||||
plane_add_comment(
|
||||
work_item_id,
|
||||
_build_analyst_ready_comment(repo, work_item_id, branch),
|
||||
# task_id is threaded through so build_status_comment can resolve the
|
||||
# analyst duration via agent_runs (ORCH-016 AC-14 DB fallback).
|
||||
_build_analyst_ready_comment(repo, work_item_id, branch, task_id=task_id),
|
||||
author="analyst",
|
||||
)
|
||||
notify_approve_requested(task_id)
|
||||
|
||||
543
src/usage.py
543
src/usage.py
@@ -1,4 +1,4 @@
|
||||
"""Feature 4: token / cost accounting for agent runs.
|
||||
"""Feature 4 + ORCH-016: token / cost accounting and unified status comments.
|
||||
|
||||
claude --output-format json emits a single result JSON object at the end of the
|
||||
run log with fields:
|
||||
@@ -8,11 +8,16 @@ run log with fields:
|
||||
modelUsage, num_turns, duration_ms
|
||||
|
||||
This module parses that JSON out of a (text-or-json) run log, records the usage
|
||||
on the agent_runs row, formats a Plane comment for the finishing agent, and
|
||||
builds the per-task summary the Deployer posts on deploy/done.
|
||||
on the agent_runs row, and builds:
|
||||
- per-agent status comments via build_status_comment(...) — the ORCH-016
|
||||
unified format replacing the legacy usage_comment(...) and the analyst-
|
||||
only stage_engine._build_analyst_ready_comment(...). Every agent now flows
|
||||
through the same hot path.
|
||||
- per-task summary the Deployer posts on deploy/done.
|
||||
|
||||
Everything here is defensive: a missing/garbled JSON never raises \u2014 we record
|
||||
NULL/0 and log a warning so a broken agent run can't crash the monitor.
|
||||
Everything here is defensive: a missing/garbled JSON never raises — we record
|
||||
NULL/0 and log a warning so a broken agent run can't crash the monitor. The
|
||||
status-comment hot path likewise NEVER raises (self-hosting risk R-1).
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -247,6 +252,88 @@ def fmt_cost(c) -> str:
|
||||
return f"${c:.2f}"
|
||||
|
||||
|
||||
def fmt_duration(seconds) -> str:
|
||||
"""Format an integer second count for the agent-finish status comment (ORCH-016).
|
||||
|
||||
Contract (ADR-001 §8 / AC-13):
|
||||
0..59 -> '{s}s' (e.g. '0s', '12s', '59s')
|
||||
60..3599 -> '{m}m {ss:02d}s' (e.g. '1m 00s', '4m 12s', '59m 59s')
|
||||
>= 3600 -> '{h}h {mm:02d}m' (seconds dropped; e.g. '1h 00m', '2h 47m')
|
||||
|
||||
None / non-int / negative -> '' so the caller drops the 'Длительность:' line.
|
||||
Pure function: no I/O, no DB.
|
||||
"""
|
||||
try:
|
||||
if seconds is None:
|
||||
return ""
|
||||
s = int(seconds)
|
||||
except (TypeError, ValueError):
|
||||
return ""
|
||||
if s < 0:
|
||||
return ""
|
||||
if s < 60:
|
||||
return f"{s}s"
|
||||
if s < 3600:
|
||||
m, ss = divmod(s, 60)
|
||||
return f"{m}m {ss:02d}s"
|
||||
h, rem = divmod(s, 3600)
|
||||
mm = rem // 60
|
||||
return f"{h}h {mm:02d}m"
|
||||
|
||||
|
||||
def get_agent_duration(task_id, agent: str) -> int | None:
|
||||
"""Last finished agent_runs duration (seconds) for (task_id, agent) — DB fallback.
|
||||
|
||||
ORCH-016 / ADR-001 §6: used by build_status_comment when the caller does NOT
|
||||
pass an explicit duration_s (chiefly the analyst path, which builds its
|
||||
comment from stage_engine where _duration_s is not in scope).
|
||||
|
||||
Reads the last finished row for (task_id, agent) via:
|
||||
SELECT CAST((julianday(finished_at) - julianday(started_at)) * 86400 AS INTEGER)
|
||||
FROM agent_runs WHERE task_id=? AND agent=?
|
||||
AND finished_at IS NOT NULL
|
||||
ORDER BY id DESC LIMIT 1
|
||||
|
||||
Returns None on any of:
|
||||
- missing task_id / agent,
|
||||
- no matching row (or finished_at IS NULL),
|
||||
- computed value < 0 (clock skew),
|
||||
- DB error (logged at debug, never re-raised). This is the hot comment
|
||||
path — a locked / stale DB must never crash a finishing agent.
|
||||
"""
|
||||
if not task_id or not agent:
|
||||
return None
|
||||
try:
|
||||
conn = get_db()
|
||||
except Exception as e:
|
||||
logger.debug(f"get_agent_duration: cannot open DB for ({task_id},{agent}): {e}")
|
||||
return None
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT CAST((julianday(finished_at) - julianday(started_at)) * 86400 AS INTEGER) "
|
||||
"FROM agent_runs WHERE task_id=? AND agent=? AND finished_at IS NOT NULL "
|
||||
"ORDER BY id DESC LIMIT 1",
|
||||
(task_id, agent),
|
||||
).fetchone()
|
||||
except Exception as e:
|
||||
logger.debug(f"get_agent_duration: query failed for ({task_id},{agent}): {e}")
|
||||
return None
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
if not row or row[0] is None:
|
||||
return None
|
||||
try:
|
||||
secs = int(row[0])
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if secs < 0:
|
||||
return None
|
||||
return secs
|
||||
|
||||
|
||||
# Pretty agent names for comments (mirrors STAGE_AUTHORS roles).
|
||||
AGENT_DISPLAY = {
|
||||
"analyst": "Analyst",
|
||||
@@ -298,30 +385,28 @@ def usage_comment(
|
||||
work_item_id: str | None = None,
|
||||
pr_number=None,
|
||||
) -> str:
|
||||
"""Build the per-agent finish comment, e.g.
|
||||
'\U0001f4bb Developer \u0433\u043e\u0442\u043e\u0432 \u00b7 8.5M in (8.4M cached) / 45.8k out \u00b7 $7.29'.
|
||||
"""DEPRECATED (ORCH-016 / ADR-001 §1): thin wrapper around build_status_comment.
|
||||
|
||||
When repo/branch/work_item_id are supplied, the agent's artifact link(s) are
|
||||
appended (BUG: only analyst used to link its docs). Missing artifacts are
|
||||
silently skipped — link building never raises.
|
||||
The historical one-line "{icon} Role готов · 8.5M in / 45.8k out · $7.29 + links"
|
||||
format has been replaced by the unified status-comment format. This wrapper
|
||||
is kept only so that legacy callers (notably the test suite in
|
||||
``tests/test_usage.py``) keep working; new code MUST call
|
||||
``build_status_comment(...)`` directly. There is no ``duration_s`` parameter
|
||||
here because the old signature did not carry it.
|
||||
"""
|
||||
usage = usage or {}
|
||||
name = AGENT_DISPLAY.get(agent, agent.capitalize())
|
||||
icon = AGENT_ICON.get(agent, "\u2705")
|
||||
line = (
|
||||
f"{icon} {name} \u0433\u043e\u0442\u043e\u0432 \u00b7 "
|
||||
f"{fmt_in(usage)} / "
|
||||
f"{fmt_tokens(usage.get('output_tokens'))} out \u00b7 "
|
||||
f"{fmt_cost(usage.get('cost_usd'))}"
|
||||
return build_status_comment(
|
||||
agent,
|
||||
repo=repo,
|
||||
branch=branch,
|
||||
work_item_id=work_item_id,
|
||||
pr_number=pr_number,
|
||||
usage=usage,
|
||||
)
|
||||
links = artifact_links(agent, repo, branch, work_item_id, pr_number)
|
||||
if links:
|
||||
line += "\n" + "\n".join(links)
|
||||
return line
|
||||
|
||||
|
||||
# Per-agent artifact file under docs/work-items/{wid}/ (architect/developer use
|
||||
# special handling for ADR dirs / PR links, see artifact_links()).
|
||||
# Per-agent artifact file under docs/work-items/{wid}/ (architect/developer/
|
||||
# deployer use special handling for ADR dirs, PR links, or staging logs —
|
||||
# see artifact_links()).
|
||||
AGENT_ARTIFACT = {
|
||||
"reviewer": ("Review", "12-review.md"),
|
||||
"tester": ("Test report", "13-test-report.md"),
|
||||
@@ -335,13 +420,35 @@ def artifact_links(
|
||||
branch: str | None,
|
||||
work_item_id: str | None,
|
||||
pr_number=None,
|
||||
*,
|
||||
stage: str | None = None,
|
||||
worktree_root: str | None = None,
|
||||
) -> list[str]:
|
||||
"""Markdown link(s) to the finishing agent's artifact(s) in Gitea.
|
||||
"""HTML <li><a>...</a></li> link fragments for the finishing agent's artifacts.
|
||||
|
||||
Uses gitea_public_url (falls back to gitea_url) for clickable links, mirroring
|
||||
the analyst doc links. Returns [] (never raises) when there is nothing to
|
||||
link or the required context is missing. analyst is intentionally NOT handled
|
||||
here — its richer doc list lives in stage_engine._build_analyst_ready_comment.
|
||||
ORCH-016 (ADR-001 §7) breaking change: this function now emits HTML anchor
|
||||
fragments to feed straight into the <ul> of build_status_comment(), instead
|
||||
of the legacy markdown ``[label](url)`` strings. The base URL still prefers
|
||||
settings.gitea_public_url (falls back to gitea_url) so links remain clickable
|
||||
from outside the deploy host, exactly like the analyst doc list.
|
||||
|
||||
Returned strings are individual ``<li><a href="...">label</a></li>`` items;
|
||||
the caller wraps them in ``<ul>...</ul>``. Empty list (never raises) when
|
||||
there is nothing to link or context is missing.
|
||||
|
||||
AC-8 graceful behaviour: when ``worktree_root`` is provided, a candidate
|
||||
whose underlying file does NOT exist in the worktree is dropped silently.
|
||||
With no worktree (unit-test / minimal context), every applicable link is
|
||||
emitted without a file-existence probe (matches the legacy artifact_links
|
||||
semantics; that's what existing tests in tests/test_usage.py exercise).
|
||||
|
||||
Per agent (ADR-001 §7, ТЗ §2.4):
|
||||
developer -> Branch + (open) PR
|
||||
architect -> ADR directory
|
||||
reviewer -> 12-review.md
|
||||
tester -> 13-test-report.md
|
||||
deployer -> 14-deploy-log.md (deploy) or 15-staging-log.md (deploy-staging)
|
||||
analyst -> NOT handled here; build_status_comment owns its richer list.
|
||||
"""
|
||||
try:
|
||||
from .config import settings
|
||||
@@ -351,37 +458,76 @@ def artifact_links(
|
||||
).rstrip("/")
|
||||
if not base or not repo:
|
||||
return []
|
||||
links: list[str] = []
|
||||
|
||||
items: list[str] = []
|
||||
rel_dir = f"docs/work-items/{work_item_id}" if work_item_id else None
|
||||
|
||||
def _file_exists(rel_path: str) -> bool:
|
||||
if not worktree_root:
|
||||
return True
|
||||
try:
|
||||
import os as _os
|
||||
return _os.path.isfile(_os.path.join(worktree_root, rel_path))
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
def _dir_exists(rel_path: str) -> bool:
|
||||
if not worktree_root:
|
||||
return True
|
||||
try:
|
||||
import os as _os
|
||||
return _os.path.isdir(_os.path.join(worktree_root, rel_path))
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
if agent == "developer":
|
||||
if branch:
|
||||
links.append(
|
||||
f"\U0001f4c2 [Branch {branch}]({base}/{owner}/{repo}/src/branch/{branch})"
|
||||
items.append(
|
||||
f'<li><a href="{base}/{owner}/{repo}/src/branch/{branch}">'
|
||||
f"Branch {branch}</a></li>"
|
||||
)
|
||||
if pr_number:
|
||||
links.append(
|
||||
f"\U0001f517 [PR #{pr_number}]({base}/{owner}/{repo}/pulls/{pr_number})"
|
||||
items.append(
|
||||
f'<li><a href="{base}/{owner}/{repo}/pulls/{pr_number}">'
|
||||
f"PR #{pr_number}</a></li>"
|
||||
)
|
||||
return links
|
||||
return items
|
||||
|
||||
if agent == "architect":
|
||||
if branch and work_item_id:
|
||||
adr_dir = (
|
||||
f"{base}/{owner}/{repo}/src/branch/{branch}/"
|
||||
f"docs/work-items/{work_item_id}/06-adr"
|
||||
)
|
||||
links.append(f"\U0001f4d0 [ADR]({adr_dir})")
|
||||
return links
|
||||
if branch and rel_dir:
|
||||
adr_rel = f"{rel_dir}/06-adr"
|
||||
if _dir_exists(adr_rel):
|
||||
items.append(
|
||||
f'<li><a href="{base}/{owner}/{repo}/src/branch/{branch}/'
|
||||
f'{adr_rel}">ADR</a></li>'
|
||||
)
|
||||
return items
|
||||
|
||||
if agent == "deployer":
|
||||
# Stage-aware (ORCH-35 + ORCH-016 §2.4): 'deploy-staging' picks the
|
||||
# staging log; 'deploy' (or unknown) picks the deploy log. Other
|
||||
# deployer artifacts (smoke output etc.) are out of scope.
|
||||
if branch and rel_dir:
|
||||
if (stage or "").strip() == "deploy-staging":
|
||||
fname, label = "15-staging-log.md", "Staging log"
|
||||
else:
|
||||
fname, label = "14-deploy-log.md", "Deploy log"
|
||||
if _file_exists(f"{rel_dir}/{fname}"):
|
||||
items.append(
|
||||
f'<li><a href="{base}/{owner}/{repo}/src/branch/{branch}/'
|
||||
f'{rel_dir}/{fname}">{label}</a></li>'
|
||||
)
|
||||
return items
|
||||
|
||||
spec = AGENT_ARTIFACT.get(agent)
|
||||
if spec and branch and work_item_id:
|
||||
if spec and branch and rel_dir:
|
||||
label, fname = spec
|
||||
href = (
|
||||
f"{base}/{owner}/{repo}/src/branch/{branch}/"
|
||||
f"docs/work-items/{work_item_id}/{fname}"
|
||||
)
|
||||
links.append(f"\U0001f4c4 [{label}]({href})")
|
||||
return links
|
||||
if _file_exists(f"{rel_dir}/{fname}"):
|
||||
items.append(
|
||||
f'<li><a href="{base}/{owner}/{repo}/src/branch/{branch}/'
|
||||
f'{rel_dir}/{fname}">{label}</a></li>'
|
||||
)
|
||||
return items
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
@@ -396,6 +542,295 @@ AGENT_ICON = {
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ORCH-016: unified status comment for every agent (analyst..deployer)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Per-agent one-line description used in the status comment header (ADR-001 §2).
|
||||
# Trailing periods are kept to match the literal assertions in AC-1..AC-5.
|
||||
_AGENT_DESCRIPTIONS = {
|
||||
"analyst": (
|
||||
"Подготовил BRD / "
|
||||
"ТЗ / Acceptance Criteria. "
|
||||
"Для продвижения "
|
||||
"переведите задачу "
|
||||
"в статус Approved. "
|
||||
"Для отклонения — "
|
||||
"напишите причину "
|
||||
"комментом и "
|
||||
"переведите в Rejected."
|
||||
),
|
||||
"architect": (
|
||||
"Завершил "
|
||||
"архитектурную "
|
||||
"проработку. "
|
||||
"См. ADR ниже."
|
||||
),
|
||||
"developer": (
|
||||
"Завершил "
|
||||
"разработку. "
|
||||
"См. PR / branch ниже."
|
||||
),
|
||||
"reviewer": (
|
||||
"Завершил "
|
||||
"ревью "
|
||||
"изменений."
|
||||
),
|
||||
"tester": (
|
||||
"Завершил "
|
||||
"прогон "
|
||||
"тестов."
|
||||
),
|
||||
"deployer": (
|
||||
"Завершил деплой."
|
||||
),
|
||||
}
|
||||
|
||||
# Analyst-specific candidate artifact list (label -> filename in docs/work-items/<wid>/).
|
||||
# Matches the legacy _build_analyst_ready_comment list 1:1 so the BUG-C
|
||||
# regression test (tests/test_analyst_comment.py) keeps passing under the
|
||||
# unified format.
|
||||
_ANALYST_CANDIDATES = [
|
||||
("Business request", "00-business-request.md"),
|
||||
("BRD", "01-brd.md"),
|
||||
("ТЗ (TRZ)", "02-trz.md"),
|
||||
("Acceptance Criteria", "03-acceptance-criteria.md"),
|
||||
("Test Plan", "04-test-plan.yaml"),
|
||||
("UI Test Cases", "04b-ui-test-cases.md"),
|
||||
]
|
||||
|
||||
|
||||
def _read_verdict_line(
|
||||
agent: str, stage: str | None, worktree_root: str | None, work_item_id: str | None
|
||||
) -> str | None:
|
||||
"""Render the optional Verdict / Status line for reviewer / tester / deployer.
|
||||
|
||||
Sources (machine-readable YAML frontmatter, via src/frontmatter.py):
|
||||
reviewer -> 12-review.md verdict: -> 'Verdict: <VALUE>'
|
||||
tester -> 13-test-report.md verdict: (or status:) -> 'Verdict: <VALUE>'
|
||||
deployer -> deploy-staging -> 15-staging-log.md staging_status: -> 'Status: <VALUE>'
|
||||
else (deploy) -> 14-deploy-log.md deploy_status: -> 'Status: <VALUE>'
|
||||
|
||||
Returns None (line suppressed) for analyst / architect / developer, when
|
||||
the worktree is unknown, the work-item id is missing, the artifact file is
|
||||
absent, or the relevant frontmatter key is not present. Never raises.
|
||||
"""
|
||||
if agent not in ("reviewer", "tester", "deployer"):
|
||||
return None
|
||||
if not worktree_root or not work_item_id:
|
||||
return None
|
||||
try:
|
||||
import os as _os
|
||||
from .frontmatter import read_frontmatter_value
|
||||
base_dir = _os.path.join(worktree_root, "docs/work-items", work_item_id)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if agent == "reviewer":
|
||||
v = read_frontmatter_value(_os.path.join(base_dir, "12-review.md"), "verdict")
|
||||
return f"Verdict: {v}" if v else None
|
||||
if agent == "tester":
|
||||
path = _os.path.join(base_dir, "13-test-report.md")
|
||||
v = read_frontmatter_value(path, "verdict")
|
||||
if not v:
|
||||
v = read_frontmatter_value(path, "status")
|
||||
return f"Verdict: {v}" if v else None
|
||||
# deployer
|
||||
if (stage or "").strip() == "deploy-staging":
|
||||
v = read_frontmatter_value(
|
||||
_os.path.join(base_dir, "15-staging-log.md"), "staging_status"
|
||||
)
|
||||
else:
|
||||
v = read_frontmatter_value(
|
||||
_os.path.join(base_dir, "14-deploy-log.md"), "deploy_status"
|
||||
)
|
||||
return f"Status: {v}" if v else None
|
||||
|
||||
|
||||
def _analyst_doc_items(
|
||||
repo: str, branch: str, work_item_id: str, worktree_root: str | None
|
||||
) -> list[str]:
|
||||
"""Build the analyst's <li><a>...</a></li> list (mirrors legacy behaviour).
|
||||
|
||||
Files absent from the worktree are skipped (graceful, as in BUG-C / PR #13).
|
||||
"""
|
||||
if not (repo and branch and work_item_id):
|
||||
return []
|
||||
from .config import settings as _settings
|
||||
owner = getattr(_settings, "gitea_owner", "admin")
|
||||
base = (
|
||||
getattr(_settings, "gitea_public_url", "") or getattr(_settings, "gitea_url", "")
|
||||
).rstrip("/")
|
||||
if not base:
|
||||
return []
|
||||
rel_dir = f"docs/work-items/{work_item_id}"
|
||||
items: list[str] = []
|
||||
for label, fname in _ANALYST_CANDIDATES:
|
||||
if worktree_root:
|
||||
try:
|
||||
import os as _os
|
||||
if not _os.path.isfile(_os.path.join(worktree_root, rel_dir, fname)):
|
||||
continue
|
||||
except Exception:
|
||||
# On filesystem error, fall through and link the candidate anyway
|
||||
# (best-effort) rather than blanking the whole document list.
|
||||
pass
|
||||
href = f"{base}/{owner}/{repo}/src/branch/{branch}/{rel_dir}/{fname}"
|
||||
items.append(f'<li><a href="{href}">{label}</a></li>')
|
||||
return items
|
||||
|
||||
|
||||
def _usage_tail(usage: dict | None) -> str | None:
|
||||
"""Render the technical token/cost tail (``<sub>...</sub>``) or None when empty.
|
||||
|
||||
Format (ADR-001 §3): ``<sub>{fmt_in} / {out} out · {cost}</sub>``.
|
||||
Returns None when usage is missing entirely AND all of the relevant
|
||||
components are zero — i.e. nothing meaningful to print.
|
||||
"""
|
||||
if not usage:
|
||||
return None
|
||||
in_total = _input_total(usage)
|
||||
try:
|
||||
out = int(usage.get("output_tokens") or 0)
|
||||
except (TypeError, ValueError):
|
||||
out = 0
|
||||
try:
|
||||
cost = float(usage.get("cost_usd") or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
cost = 0.0
|
||||
if in_total == 0 and out == 0 and cost == 0.0:
|
||||
return None
|
||||
return f"<sub>{fmt_in(usage)} / {fmt_tokens(out)} out · {fmt_cost(cost)}</sub>"
|
||||
|
||||
|
||||
def build_status_comment(
|
||||
agent: str,
|
||||
*,
|
||||
repo: str | None = None,
|
||||
branch: str | None = None,
|
||||
work_item_id: str | None = None,
|
||||
pr_number=None,
|
||||
stage: str | None = None,
|
||||
usage: dict | None = None,
|
||||
duration_s=None,
|
||||
task_id=None,
|
||||
worktree_root: str | None = None,
|
||||
) -> str:
|
||||
"""Build the unified per-agent finish comment (ORCH-016 / ADR-001).
|
||||
|
||||
Single hot path for every agent's "I just finished a stage" comment in
|
||||
Plane. Replaces the old ``usage_comment(...)`` one-liner AND the analyst-
|
||||
special ``stage_engine._build_analyst_ready_comment(...)`` HTML; both now
|
||||
flow through here. Format (HTML, rendered by Plane), separated by ``<br>``::
|
||||
|
||||
{ICON} {RoleName} — {DESCRIPTION}
|
||||
[Verdict|Status: VALUE] # reviewer/tester/deployer + FM
|
||||
[Длительность: 4m 12s]
|
||||
<b>Документы:</b><ul><li><a href="...">label</a></li>...</ul>
|
||||
[<sub>8.5M in / 45.8k out · $7.29</sub>]
|
||||
|
||||
Arguments (all keyword-only except ``agent``):
|
||||
agent one of analyst|architect|developer|reviewer|tester|deployer.
|
||||
Unknown agents get a generic header — defensive.
|
||||
repo/branch repository name + feature branch. Required for artifact
|
||||
links; without them the Документы block is omitted.
|
||||
work_item_id Plane work-item id used as the docs/work-items/<id>/ slug.
|
||||
pr_number developer only — appended as a PR link when set.
|
||||
stage deployer only — 'deploy' vs 'deploy-staging' picks the
|
||||
log file (14- vs 15-) and the verdict frontmatter key.
|
||||
usage parsed token/cost dict (from parse_usage_from_text). When
|
||||
None or all-zero the ``<sub>`` tail is suppressed.
|
||||
duration_s explicit per-agent wall-clock seconds. If None and
|
||||
task_id is given, falls back to
|
||||
get_agent_duration(task_id, agent). Negative / non-int
|
||||
values are treated as unknown.
|
||||
task_id tasks.id — required for the DB duration fallback. The
|
||||
verdict / artifact code paths do NOT depend on it.
|
||||
worktree_root path to the task's git worktree. Drives AC-8 graceful
|
||||
skipping of missing files AND the verdict frontmatter
|
||||
read. Omit (None) in unit tests where only format matters.
|
||||
|
||||
The function MUST NOT raise — at worst it returns a degraded one-liner
|
||||
header, with the exception logged. Self-hosting risk R-1: a crash here
|
||||
blinds the stakeholder for that very ORCH task.
|
||||
"""
|
||||
try:
|
||||
name = AGENT_DISPLAY.get(agent, (agent or "agent").capitalize())
|
||||
icon = AGENT_ICON.get(agent, "✅")
|
||||
description = _AGENT_DESCRIPTIONS.get(
|
||||
agent,
|
||||
"завершил стадию.",
|
||||
)
|
||||
if agent == "deployer":
|
||||
if (stage or "").strip() == "deploy-staging":
|
||||
description = (
|
||||
"Завершил "
|
||||
"staging-деплой."
|
||||
)
|
||||
elif (stage or "").strip() == "deploy":
|
||||
description = (
|
||||
"Завершил "
|
||||
"прод-деплой."
|
||||
)
|
||||
|
||||
lines: list[str] = [f"{icon} {name} — {description}"]
|
||||
|
||||
verdict_line = _read_verdict_line(agent, stage, worktree_root, work_item_id)
|
||||
if verdict_line:
|
||||
lines.append(verdict_line)
|
||||
|
||||
# Duration: explicit param wins; otherwise DB fallback (ADR-001 §6).
|
||||
resolved_duration: int | None = None
|
||||
if duration_s is not None:
|
||||
try:
|
||||
if int(duration_s) >= 0:
|
||||
resolved_duration = int(duration_s)
|
||||
except (TypeError, ValueError):
|
||||
resolved_duration = None
|
||||
if resolved_duration is None and task_id is not None:
|
||||
resolved_duration = get_agent_duration(task_id, agent)
|
||||
d_text = fmt_duration(resolved_duration)
|
||||
if d_text:
|
||||
lines.append(
|
||||
"Длительность: "
|
||||
f"{d_text}"
|
||||
)
|
||||
|
||||
# Documents block (analyst gets its full BRD/TRZ/AC/Test Plan list).
|
||||
if agent == "analyst":
|
||||
doc_items = _analyst_doc_items(
|
||||
repo or "", branch or "", work_item_id or "", worktree_root
|
||||
)
|
||||
else:
|
||||
doc_items = artifact_links(
|
||||
agent, repo, branch, work_item_id, pr_number,
|
||||
stage=stage, worktree_root=worktree_root,
|
||||
)
|
||||
if doc_items:
|
||||
lines.append(
|
||||
"<b>Документы:</b><ul>"
|
||||
+ "".join(doc_items)
|
||||
+ "</ul>"
|
||||
)
|
||||
|
||||
tail = _usage_tail(usage)
|
||||
if tail:
|
||||
lines.append(tail)
|
||||
|
||||
return "<br>".join(lines)
|
||||
except Exception as e: # defensive — R-1 fallback
|
||||
logger.exception(f"build_status_comment failed for agent={agent}: {e}")
|
||||
try:
|
||||
name = AGENT_DISPLAY.get(agent, str(agent).capitalize())
|
||||
icon = AGENT_ICON.get(agent, "✅")
|
||||
return (
|
||||
f"{icon} {name} "
|
||||
"готов"
|
||||
)
|
||||
except Exception:
|
||||
return "✅ Agent готов"
|
||||
|
||||
|
||||
def task_usage_summary(task_id: int) -> dict:
|
||||
"""Aggregate agent_runs usage for a task.
|
||||
|
||||
@@ -441,14 +876,14 @@ def task_summary_comment(task_id: int) -> str:
|
||||
s = task_usage_summary(task_id)
|
||||
cached = s.get("total_cached", 0)
|
||||
head_in = (
|
||||
f"{fmt_tokens(s['total_in'])} \u0432\u0445\u043e\u0434 ({fmt_tokens(cached)} cached)"
|
||||
f"{fmt_tokens(s['total_in'])} вход ({fmt_tokens(cached)} cached)"
|
||||
if cached > 0
|
||||
else f"{fmt_tokens(s['total_in'])} \u0432\u0445\u043e\u0434"
|
||||
else f"{fmt_tokens(s['total_in'])} вход"
|
||||
)
|
||||
lines = [
|
||||
f"\U0001f4ca \u0418\u0442\u043e\u0433\u043e \u043f\u043e \u0437\u0430\u0434\u0430\u0447\u0435: "
|
||||
f"\U0001f4ca Итого по задаче: "
|
||||
f"{head_in} / "
|
||||
f"{fmt_tokens(s['total_out'])} \u0432\u044b\u0445\u043e\u0434 \u00b7 "
|
||||
f"{fmt_tokens(s['total_out'])} выход · "
|
||||
f"{fmt_cost(s['total_cost'])}"
|
||||
]
|
||||
for agent, ti, tc, to, cost in s["per_agent"]:
|
||||
@@ -459,6 +894,6 @@ def task_summary_comment(task_id: int) -> str:
|
||||
else f"{fmt_tokens(ti)} in"
|
||||
)
|
||||
lines.append(
|
||||
f"\u2022 {name}: {in_str} / {fmt_tokens(to)} out \u00b7 {fmt_cost(cost)}"
|
||||
f"• {name}: {in_str} / {fmt_tokens(to)} out · {fmt_cost(cost)}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -137,7 +137,7 @@ async def handle_issue_updated(data: dict, project_id: str = ""):
|
||||
Any other status (Needs Input, In Review, Blocked, Done, board stages, etc.)
|
||||
is ignored here — those are statuses the orchestrator itself sets.
|
||||
"""
|
||||
from ..plane_sync import PLANE_STATES
|
||||
from ..plane_sync import get_project_states
|
||||
|
||||
plane_id = str(data.get("id") or "")
|
||||
new_state = _state_id(data)
|
||||
@@ -145,11 +145,15 @@ async def handle_issue_updated(data: dict, project_id: str = ""):
|
||||
logger.info("issue updated without id/state, ignoring")
|
||||
return
|
||||
|
||||
if new_state == PLANE_STATES["in_progress"]:
|
||||
# ORCH-10: resolve expected state UUIDs per the incoming issue's project so
|
||||
# both enduro (b873d9eb) and orchestrator (e331bfb3) In Progress trigger the
|
||||
# pipeline. Using PLANE_STATES["in_progress"] here was the root-cause blocker.
|
||||
proj_states = get_project_states(project_id)
|
||||
if new_state == proj_states["in_progress"]:
|
||||
await handle_status_start(data, project_id)
|
||||
elif new_state == PLANE_STATES["approved"]:
|
||||
elif new_state == proj_states["approved"]:
|
||||
await handle_verdict(data, project_id, approved=True)
|
||||
elif new_state == PLANE_STATES["rejected"]:
|
||||
elif new_state == proj_states["rejected"]:
|
||||
await handle_verdict(data, project_id, approved=False)
|
||||
else:
|
||||
logger.info(f"issue {plane_id} updated to state {new_state[:8]}..., no pipeline action")
|
||||
@@ -422,7 +426,7 @@ async def start_pipeline(data: dict, project_id: str = ""):
|
||||
if errors:
|
||||
# QG-0 failed
|
||||
error_text = "\u26a0\ufe0f QG-0 failed:\n" + "\n".join(f"\u2022 {e}" for e in errors)
|
||||
from ..plane_sync import PLANE_BASE, PLANE_HEADERS, WORKSPACE, PLANE_STATES
|
||||
from ..plane_sync import PLANE_BASE, PLANE_HEADERS, WORKSPACE, get_project_states
|
||||
import httpx as _httpx
|
||||
# Post comment (ORCH-6: route to the issue's own project)
|
||||
url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{plane_project_id}/issues/{plane_id}/comments/"
|
||||
@@ -431,11 +435,12 @@ async def start_pipeline(data: dict, project_id: str = ""):
|
||||
json={"comment_html": f"<p>{error_text}</p>"}, timeout=10)
|
||||
except Exception:
|
||||
pass
|
||||
# Set blocked
|
||||
# Set blocked — ORCH-10: resolve per-project UUID.
|
||||
url2 = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{plane_project_id}/issues/{plane_id}/"
|
||||
try:
|
||||
_blocked = get_project_states(plane_project_id)["blocked"]
|
||||
_httpx.patch(url2, headers=PLANE_HEADERS,
|
||||
json={"state": PLANE_STATES["blocked"]}, timeout=10)
|
||||
json={"state": _blocked}, timeout=10)
|
||||
except Exception:
|
||||
pass
|
||||
logger.info(f"QG-0 failed for {plane_id}: {errors}")
|
||||
|
||||
126
tests/test_analyst_comment_regression.py
Normal file
126
tests/test_analyst_comment_regression.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""ORCH-016 / TC-11 + AC-6: analyst status-comment regression.
|
||||
|
||||
Status-only verdict model from PR #12 / #13 must be preserved exactly:
|
||||
- the analyst comment still asks the stakeholder for the **Approved** status,
|
||||
- it still rejects the obsolete ``:approved:`` reaction and "move to In Progress",
|
||||
- it still links the documents that actually exist (BRD / TRZ / AC / Test Plan,
|
||||
skipping anything not on disk),
|
||||
- it now also carries the new «Длительность: …» line when an agent_runs row
|
||||
exists for (task_id, analyst).
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
|
||||
_test_db = os.path.join(tempfile.gettempdir(), "test_orch016_analyst_regression.db")
|
||||
os.environ["ORCH_DB_PATH"] = _test_db
|
||||
|
||||
import pytest # noqa: E402
|
||||
|
||||
from src import db as db_module # noqa: E402
|
||||
from src.db import init_db, get_db # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_db(monkeypatch):
|
||||
monkeypatch.setattr(db_module.settings, "db_path", _test_db, raising=False)
|
||||
if os.path.exists(_test_db):
|
||||
os.unlink(_test_db)
|
||||
init_db()
|
||||
yield
|
||||
if os.path.exists(_test_db):
|
||||
os.unlink(_test_db)
|
||||
|
||||
|
||||
def _seed_task_and_analyst_run(task_id=42, agent="analyst", duration_seconds=180):
|
||||
"""Insert a task and a finished analyst run with a measurable duration."""
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"INSERT INTO tasks (id, repo, branch, stage, work_item_id) "
|
||||
"VALUES (?, 'orchestrator', 'feature/ORCH-016', 'analysis', 'ORCH-016')",
|
||||
(task_id,),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO agent_runs (task_id, agent, started_at, finished_at) "
|
||||
"VALUES (?, ?, datetime('now', ?), datetime('now'))",
|
||||
(task_id, agent, f"-{duration_seconds} seconds"),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_tc11_analyst_text_preserved_with_links(monkeypatch, tmp_path):
|
||||
"""Analyst comment must keep all existing assertions from PR #12 / #13."""
|
||||
from src import stage_engine as SE
|
||||
from src.config import settings
|
||||
|
||||
wt = tmp_path / "wt"
|
||||
docs = wt / "docs" / "work-items" / "ET-011"
|
||||
docs.mkdir(parents=True)
|
||||
for fname in (
|
||||
"00-business-request.md", "01-brd.md", "02-trz.md",
|
||||
"03-acceptance-criteria.md", "04-test-plan.yaml",
|
||||
):
|
||||
(docs / fname).write_text("x")
|
||||
# 04b-ui-test-cases.md intentionally absent
|
||||
|
||||
monkeypatch.setattr(SE, "get_worktree_path", lambda repo, branch: str(wt))
|
||||
monkeypatch.setattr(settings, "gitea_url", "http://localhost:3000", raising=False)
|
||||
monkeypatch.setattr(
|
||||
settings, "gitea_public_url", "https://git.mva154.duckdns.org", raising=False
|
||||
)
|
||||
monkeypatch.setattr(settings, "gitea_owner", "admin", raising=False)
|
||||
|
||||
html = SE._build_analyst_ready_comment(
|
||||
"enduro-trails", "ET-011", "feature/ET-011-gpx-upload-feature",
|
||||
)
|
||||
|
||||
# Status-only verdict text (PR #12 contract).
|
||||
assert "Approved" in html
|
||||
assert "Rejected" in html
|
||||
assert ":approved:" not in html
|
||||
assert "In Progress" not in html
|
||||
|
||||
# Clickable links via public URL only.
|
||||
assert "<a href=" in html
|
||||
base = ("https://git.mva154.duckdns.org/admin/enduro-trails/src/branch/"
|
||||
"feature/ET-011-gpx-upload-feature/docs/work-items/ET-011/")
|
||||
assert base + "01-brd.md" in html
|
||||
assert base + "04-test-plan.yaml" in html
|
||||
|
||||
# Missing file NOT linked.
|
||||
assert "04b-ui-test-cases.md" not in html
|
||||
|
||||
# Internal URL must NOT leak into clickable links.
|
||||
assert "localhost:3000" not in html
|
||||
|
||||
|
||||
def test_tc11_analyst_includes_duration_when_db_has_run(monkeypatch, tmp_path):
|
||||
"""When an agent_runs row exists for (task_id, analyst), the comment carries
|
||||
a «Длительность:» line populated via the DB fallback (AC-14)."""
|
||||
from src import stage_engine as SE
|
||||
from src.config import settings
|
||||
|
||||
wt = tmp_path / "wt"
|
||||
(wt / "docs" / "work-items" / "ORCH-016").mkdir(parents=True)
|
||||
(wt / "docs" / "work-items" / "ORCH-016" / "01-brd.md").write_text("x")
|
||||
|
||||
_seed_task_and_analyst_run(task_id=42, agent="analyst", duration_seconds=125)
|
||||
|
||||
monkeypatch.setattr(SE, "get_worktree_path", lambda repo, branch: str(wt))
|
||||
monkeypatch.setattr(settings, "gitea_url", "http://localhost:3000", raising=False)
|
||||
monkeypatch.setattr(settings, "gitea_public_url", "", raising=False)
|
||||
monkeypatch.setattr(settings, "gitea_owner", "admin", raising=False)
|
||||
|
||||
html = SE._build_analyst_ready_comment(
|
||||
"orchestrator", "ORCH-016", "feature/ORCH-016", task_id=42,
|
||||
)
|
||||
|
||||
# Two-digit seconds rounding may shave ~1s — accept either neighbour.
|
||||
assert any(
|
||||
s in html
|
||||
for s in ("Длительность: 2m 05s", "Длительность: 2m 04s", "Длительность: 2m 06s")
|
||||
), html
|
||||
135
tests/test_analyst_status_only_regression.py
Normal file
135
tests/test_analyst_status_only_regression.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""ORCH-016 / TC-16 + AC-6: analyst status-only regression.
|
||||
|
||||
Status-only verdict model (PR #12 / #13):
|
||||
- analyst finishes its run -> Plane state becomes In Review,
|
||||
- ONE status comment is posted asking the stakeholder to flip the status to
|
||||
Approved (or write a reason and switch to Rejected),
|
||||
- NO auto-advance happens — the next stage waits for human approval.
|
||||
|
||||
The ORCH-016 PR refactors the comment text into the unified status-comment
|
||||
helper. This regression test guards against:
|
||||
(a) the analyst path silently auto-advancing,
|
||||
(b) the analyst comment losing the «Approved» / «Rejected» instruction text,
|
||||
(c) the comment switching authorship away from the analyst bot.
|
||||
|
||||
We exercise `_handle_analysis_approved_flow` directly (the launcher path).
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
|
||||
_test_db = os.path.join(tempfile.gettempdir(), "test_orch016_analyst_so.db")
|
||||
os.environ["ORCH_DB_PATH"] = _test_db
|
||||
|
||||
import pytest # noqa: E402
|
||||
|
||||
from src import db as db_module # noqa: E402
|
||||
from src.db import init_db, get_db # noqa: E402
|
||||
|
||||
|
||||
REPO = "enduro-trails"
|
||||
BRANCH = "feature/ET-016-x"
|
||||
WID = "ET-016"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_db(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(db_module.settings, "db_path", _test_db, raising=False)
|
||||
if os.path.exists(_test_db):
|
||||
os.unlink(_test_db)
|
||||
init_db()
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"INSERT INTO tasks (id, repo, branch, stage, work_item_id) "
|
||||
"VALUES (1, ?, ?, 'analysis', ?)",
|
||||
(REPO, BRANCH, WID),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
yield
|
||||
if os.path.exists(_test_db):
|
||||
os.unlink(_test_db)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_worktree(monkeypatch, tmp_path):
|
||||
base = tmp_path / "wt"
|
||||
docs = base / "docs" / "work-items" / WID
|
||||
docs.mkdir(parents=True)
|
||||
# All analyst artifacts present -> "files_check" returns True.
|
||||
for f in ("01-brd.md", "02-trz.md", "03-acceptance-criteria.md",
|
||||
"04-test-plan.yaml"):
|
||||
(docs / f).write_text("x")
|
||||
monkeypatch.setattr("src.git_worktree.get_worktree_path", lambda r, b: str(base))
|
||||
monkeypatch.setattr("src.stage_engine.get_worktree_path", lambda r, b: str(base))
|
||||
monkeypatch.setattr("src.qg.checks.get_worktree_path", lambda r, b: str(base))
|
||||
return base
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def collect_calls(monkeypatch):
|
||||
calls = {"in_review": 0, "advance": 0, "comments": [], "enqueued": []}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.stage_engine.set_issue_in_review",
|
||||
lambda wid: calls.__setitem__("in_review", calls["in_review"] + 1),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.stage_engine.notify_approve_requested", lambda tid: None
|
||||
)
|
||||
|
||||
def _add_comment(wid, body, author=None, **kw):
|
||||
calls["comments"].append({"wid": wid, "body": body, "author": author})
|
||||
|
||||
monkeypatch.setattr("src.stage_engine.plane_add_comment", _add_comment)
|
||||
|
||||
# advance_stage isn't directly hit; if anything calls update_task_stage to
|
||||
# 'architecture', we'd see it here.
|
||||
def _update_task_stage(task_id, stage):
|
||||
calls["advance"] += 1
|
||||
|
||||
monkeypatch.setattr("src.stage_engine.update_task_stage", _update_task_stage)
|
||||
|
||||
def _enqueue(*a, **k):
|
||||
calls["enqueued"].append((a, k))
|
||||
return 1
|
||||
|
||||
monkeypatch.setattr("src.stage_engine.enqueue_job", _enqueue)
|
||||
return calls
|
||||
|
||||
|
||||
def test_tc16_analyst_goes_to_in_review_no_advance(fake_worktree, collect_calls):
|
||||
"""When the analyst finishes with complete artifacts, the task goes to In
|
||||
Review and NO advance/enqueue happens — the human approves via Plane status.
|
||||
"""
|
||||
from src.stage_engine import _handle_analysis_approved_flow, AdvanceResult
|
||||
|
||||
result = AdvanceResult(from_stage="analysis")
|
||||
_handle_analysis_approved_flow(
|
||||
task_id=1, current_stage="analysis", repo=REPO, work_item_id=WID,
|
||||
branch=BRANCH, agent="analyst", result=result,
|
||||
)
|
||||
|
||||
# In Review state requested in Plane.
|
||||
assert collect_calls["in_review"] == 1, collect_calls
|
||||
# NO stage-machine advance.
|
||||
assert collect_calls["advance"] == 0, collect_calls
|
||||
# NO new job enqueued by the analyst path.
|
||||
assert collect_calls["enqueued"] == [], collect_calls
|
||||
|
||||
# Exactly one comment posted, authored by analyst, with required text bits.
|
||||
assert len(collect_calls["comments"]) == 1, collect_calls["comments"]
|
||||
c = collect_calls["comments"][0]
|
||||
assert c["wid"] == WID
|
||||
assert c["author"] == "analyst"
|
||||
body = c["body"]
|
||||
assert "Approved" in body
|
||||
assert "Rejected" in body
|
||||
assert ":approved:" not in body
|
||||
assert "In Progress" not in body
|
||||
# AC-6 +: the new unified format adds a Длительность line (DB fallback).
|
||||
# No agent_runs row exists in this test, so the line should be ABSENT.
|
||||
assert "Длительность" not in body
|
||||
68
tests/test_fmt_duration.py
Normal file
68
tests/test_fmt_duration.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""ORCH-016 / AC-13 + AC-22: fmt_duration formatting contract.
|
||||
|
||||
Pure-function tests for the duration formatter used by build_status_comment.
|
||||
No DB, no I/O — just the table in ADR-001 §8 / AC-13.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
|
||||
from src.usage import fmt_duration # noqa: E402
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-21: table-driven happy path (AC-13)
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_fmt_duration_boundary_table():
|
||||
cases = [
|
||||
(0, "0s"),
|
||||
(12, "12s"),
|
||||
(59, "59s"),
|
||||
(60, "1m 00s"),
|
||||
(252, "4m 12s"),
|
||||
(3599, "59m 59s"),
|
||||
(3600, "1h 00m"),
|
||||
(3780, "1h 03m"),
|
||||
(10020, "2h 47m"),
|
||||
]
|
||||
for seconds, expected in cases:
|
||||
assert fmt_duration(seconds) == expected, (
|
||||
f"fmt_duration({seconds}) -> {fmt_duration(seconds)!r}; expected {expected!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-22: None / negative -> empty string (caller drops the line) (AC-13)
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_fmt_duration_none_returns_empty():
|
||||
assert fmt_duration(None) == ""
|
||||
|
||||
|
||||
def test_fmt_duration_negative_returns_empty():
|
||||
assert fmt_duration(-1) == ""
|
||||
assert fmt_duration(-3600) == ""
|
||||
|
||||
|
||||
def test_fmt_duration_garbage_returns_empty():
|
||||
# Non-coercible input must not raise (defensive).
|
||||
assert fmt_duration("abc") == ""
|
||||
assert fmt_duration([1, 2]) == ""
|
||||
|
||||
|
||||
def test_fmt_duration_float_seconds_truncated():
|
||||
# int(12.9) == 12 — integer truncation, not rounding.
|
||||
assert fmt_duration(12.9) == "12s"
|
||||
assert fmt_duration(61.4) == "1m 01s"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Caller contract: empty string => the 'Длительность:' line is NOT printed.
|
||||
# build_status_comment is unit-tested in test_status_comment_format; here we
|
||||
# just sanity-check the helper used to gate that decision.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_empty_string_is_falsy():
|
||||
assert not fmt_duration(None)
|
||||
assert not fmt_duration(-5)
|
||||
assert fmt_duration(0) # "0s" IS truthy: AC-13 wants the line printed
|
||||
@@ -34,6 +34,27 @@ import src.plane_sync as plane_sync # noqa: E402
|
||||
ORCH_PLANE_ID = "8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a"
|
||||
ENDURO_PLANE_ID = "7a79f0a9-5278-49cd-9007-9a338f238f9c"
|
||||
|
||||
# ORCH-39: after ORCH-10 the webhook resolves Plane state UUIDs per-project via
|
||||
# get_project_states(project_id). Mock it deterministically (no network) and
|
||||
# send each request with the UUID that matches its own project.
|
||||
_PROJECT_STATES = {
|
||||
ENDURO_PLANE_ID: {
|
||||
"in_progress": "b873d9eb-993c-48cd-97ac-99a9b1623967",
|
||||
"approved": "a519a341-dada-4a91-8910-7604f82b79c5",
|
||||
"rejected": "ba958f3c-5db5-461d-8f82-89425e413b97",
|
||||
},
|
||||
ORCH_PLANE_ID: {
|
||||
"in_progress": "e331bfb3-e17e-4699-ba48-4abb89c21b7b",
|
||||
"approved": "63f2c8fe-dcda-4ace-952f-dd88bd0118ff",
|
||||
"rejected": "4c769e90-bf80-4a52-b97a-e1c84904bfc3",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _fake_get_project_states(project_id):
|
||||
return _PROJECT_STATES.get(project_id, _PROJECT_STATES[ENDURO_PLANE_ID])
|
||||
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
@@ -48,6 +69,10 @@ def setup(monkeypatch):
|
||||
|
||||
monkeypatch.setattr("src.webhooks.plane.verify_plane_signature", lambda body, sig: True)
|
||||
|
||||
# ORCH-39: deterministic per-project Plane states, clean cache per test.
|
||||
plane_sync.reload_project_states()
|
||||
monkeypatch.setattr(plane_sync, "get_project_states", _fake_get_project_states)
|
||||
|
||||
registry_json = (
|
||||
f'[{{"plane_project_id": "{ENDURO_PLANE_ID}", "repo": "enduro-trails",'
|
||||
f' "work_item_prefix": "ET", "name": "enduro-trails"}},'
|
||||
@@ -60,6 +85,7 @@ def setup(monkeypatch):
|
||||
yield
|
||||
|
||||
reload_projects()
|
||||
plane_sync.reload_project_states()
|
||||
if os.path.exists(_test_db):
|
||||
os.unlink(_test_db)
|
||||
|
||||
@@ -103,10 +129,9 @@ def test_fetch_sequence_id_missing_field_returns_none():
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Feature 1: pipeline starts on a status change to In Progress, not on creation.
|
||||
_IN_PROGRESS = "b873d9eb-993c-48cd-97ac-99a9b1623967"
|
||||
|
||||
|
||||
# ORCH-39: in_progress UUID is project-specific; derive it from the project.
|
||||
def _post(plane_id, plane_project_id=ORCH_PLANE_ID, name="A valid work item title"):
|
||||
in_progress = _fake_get_project_states(plane_project_id)["in_progress"]
|
||||
return client.post(
|
||||
"/webhook/plane",
|
||||
json={
|
||||
@@ -117,7 +142,7 @@ def _post(plane_id, plane_project_id=ORCH_PLANE_ID, name="A valid work item titl
|
||||
"name": name,
|
||||
"description_stripped": "This is a sufficiently long description.",
|
||||
"project": plane_project_id,
|
||||
"state": {"id": _IN_PROGRESS, "name": "In Progress", "group": "started"},
|
||||
"state": {"id": in_progress, "name": "In Progress", "group": "started"},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
79
tests/test_notify_done_regression.py
Normal file
79
tests/test_notify_done_regression.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""ORCH-016 / TC-18 + AC-7: notify_done / set_issue_done not regressed.
|
||||
|
||||
The final deploy -> done transition still posts the «✅ Task completed!»
|
||||
comment under the deployer bot, alongside the new ORCH-016 status comment
|
||||
the deployer publishes when it finishes the stage. The two comments are
|
||||
independent — the status comment doesn't replace `notify_done`.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
|
||||
from src import plane_sync as PS # noqa: E402
|
||||
|
||||
|
||||
def test_notify_done_constants_unchanged():
|
||||
# Emoji + message body — pinned to lock the contract.
|
||||
assert PS.EMOJI_DONE == "✅"
|
||||
|
||||
|
||||
def test_notify_done_posts_completed_comment(monkeypatch):
|
||||
"""plane_sync.notify_done still posts the ✅ Task completed! comment
|
||||
authored by the deployer."""
|
||||
captured = {}
|
||||
|
||||
def _spy_update(work_item_id, state, project_id=None):
|
||||
captured["update"] = (work_item_id, state, project_id)
|
||||
|
||||
def _spy_add(work_item_id, body, project_id=None, author=None, **kw):
|
||||
captured.setdefault("comments", []).append(
|
||||
{"wid": work_item_id, "body": body, "author": author}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(PS, "update_issue_state", _spy_update)
|
||||
monkeypatch.setattr(PS, "add_comment", _spy_add)
|
||||
monkeypatch.setattr(PS, "_resolve_project_id", lambda wid, pid=None: "p-1")
|
||||
|
||||
PS.notify_done("ET-016")
|
||||
|
||||
assert captured["update"] == ("ET-016", "done", "p-1")
|
||||
assert len(captured["comments"]) == 1
|
||||
c = captured["comments"][0]
|
||||
assert c["wid"] == "ET-016"
|
||||
assert c["author"] == "deployer"
|
||||
# Body untouched: emoji + canonical Russian/English copy.
|
||||
assert "✅" in c["body"]
|
||||
assert "Task completed" in c["body"]
|
||||
|
||||
|
||||
def test_set_issue_done_still_exported():
|
||||
"""set_issue_done must remain importable from plane_sync — stage_engine
|
||||
line ~269 invokes it on deploy->done. ORCH-016 must not remove or rename it.
|
||||
"""
|
||||
assert callable(getattr(PS, "set_issue_done", None))
|
||||
# And stage_engine still imports it at the module level (regression: ORCH-016
|
||||
# touches stage_engine to wire the new analyst comment helper).
|
||||
from src import stage_engine as SE
|
||||
assert getattr(SE, "set_issue_done", None) is PS.set_issue_done
|
||||
|
||||
|
||||
def test_orch016_does_not_steal_done_signal(monkeypatch):
|
||||
"""build_status_comment is just a comment — it must NOT call set_issue_done
|
||||
or notify_done as a side effect (that's stage_engine's job)."""
|
||||
from src import usage as U
|
||||
called = {"done": 0, "in_review": 0}
|
||||
|
||||
def _fail(*a, **k):
|
||||
called["done"] += 1
|
||||
|
||||
monkeypatch.setattr(PS, "set_issue_done", _fail)
|
||||
monkeypatch.setattr(PS, "notify_done", _fail)
|
||||
|
||||
html = U.build_status_comment(
|
||||
"deployer", repo="enduro-trails", branch="b", work_item_id="ET-016",
|
||||
stage="deploy", duration_s=12,
|
||||
)
|
||||
assert "\U0001f680 Deployer" in html
|
||||
assert called["done"] == 0
|
||||
462
tests/test_orch10_states.py
Normal file
462
tests/test_orch10_states.py
Normal file
@@ -0,0 +1,462 @@
|
||||
"""ORCH-10: per-project Plane state resolution tests.
|
||||
|
||||
Verifies:
|
||||
1. get_project_states(ET_PROJECT_ID) -> enduro-trails UUIDs (backward compat).
|
||||
2. get_project_states(ORCH_PROJECT_ID) -> orchestrator UUIDs.
|
||||
3. get_project_states falls back to _DEFAULT_STATES when the Plane API fails.
|
||||
4. _STATES_CACHE is populated after a successful call and reload_project_states
|
||||
evicts it (per-project and full flush).
|
||||
5. stage_to_state() resolves per-project UUIDs for both projects.
|
||||
6. Webhook handle_issue_updated recognises In Progress for BOTH projects
|
||||
(ORCH-10 critical path: e331bfb3 for ORCH, b873d9eb for ET -> pipeline start).
|
||||
7. Webhook handle_issue_updated recognises Approved/Rejected per project.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from unittest.mock import patch, MagicMock, AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Minimal env so src/config.py can import without a real .env file.
|
||||
# ---------------------------------------------------------------------------
|
||||
os.environ.setdefault("ORCH_PLANE_API_URL", "http://plane.local")
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_PLANE_WORKSPACE_SLUG", "test-ws")
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_PLANE_WEBHOOK_SECRET", "")
|
||||
os.environ.setdefault("ORCH_GITEA_WEBHOOK_SECRET", "")
|
||||
|
||||
_test_db = os.path.join(tempfile.gettempdir(), "test_orch10_states.db")
|
||||
os.environ["ORCH_DB_PATH"] = _test_db
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Known UUIDs from the ТЗ (source of truth).
|
||||
# ---------------------------------------------------------------------------
|
||||
ET_PROJECT_ID = "7a79f0a9-5278-49cd-9007-9a338f238f9c"
|
||||
ORCH_PROJECT_ID = "8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a"
|
||||
|
||||
ET_STATES = {
|
||||
"backlog": "113b24f6-cce8-4be9-9a22-a359b9cf0122",
|
||||
"todo": "2c7d3df3-9eb9-419b-92b7-d7d560bcdd10",
|
||||
"in_progress": "b873d9eb-993c-48cd-97ac-99a9b1623967",
|
||||
"architecture": "3020bbb7-6122-4663-930c-0315ba8dfa3d",
|
||||
"development": "9920609b-f140-4e46-ab95-89acda8412c8",
|
||||
"review": "ba0d802c-5218-41d4-ab43-978b0ea123ed",
|
||||
"testing": "7855d807-b1bf-42ef-8dae-6cde0df92d02",
|
||||
"approved": "a519a341-dada-4a91-8910-7604f82b79c5",
|
||||
"rejected": "ba958f3c-5db5-461d-8f82-89425e413b97",
|
||||
"done": "381a2833-3c4e-4be5-bd0f-be84cb946ad8",
|
||||
"cancelled": "b1cae7f9-961d-4889-a179-f3acea697d17",
|
||||
"needs_input": "babf08a3-ff4d-41f3-a821-5491aa29a8ac",
|
||||
"in_review": "38fb1f64-aa1e-48a3-92e0-0b109679046b",
|
||||
"blocked": "6c4543f9-ac47-4ef7-ae0f-070020dc9920",
|
||||
}
|
||||
|
||||
ORCH_STATES = {
|
||||
"backlog": "2d5d42ff-e94d-4209-a664-8020c28c2a95",
|
||||
"todo": "b5d3f512-4870-460f-bf6b-4ea560f00a6f",
|
||||
"in_progress": "e331bfb3-e17e-4699-ba48-4abb89c21b7b",
|
||||
"architecture": "795cc32f-5f5a-4244-be7b-9acffc92c7c0",
|
||||
"development": "f5ed4705-5029-470d-89a9-54c3f0d211ee",
|
||||
"review": "2026f3d9-0f43-4054-ab5f-3f9bae3308b8",
|
||||
"testing": "81c5cd78-2993-4f2c-9e8c-2f52db3e5623",
|
||||
"approved": "63f2c8fe-dcda-4ace-952f-dd88bd0118ff",
|
||||
"rejected": "4c769e90-bf80-4a52-b97a-e1c84904bfc3",
|
||||
"done": "3738cd3c-7610-4907-ba5e-26b9a248d9c0",
|
||||
"cancelled": "59d1d210-8e3a-4a83-930a-cbc5dbf6ad85",
|
||||
"needs_input": "99978b3f-72fe-46e3-8b9b-25ba02899fa0",
|
||||
"in_review": "c52e99b9-31ae-4b31-be3f-9773eea7a747",
|
||||
"blocked": "505f01a6-a12f-4121-aaa7-9c5dd009acc4",
|
||||
}
|
||||
|
||||
|
||||
def _make_states_response(states_dict: dict) -> dict:
|
||||
"""Build a fake Plane GET /states/ response."""
|
||||
name_map = {v: k for k, v in {
|
||||
"backlog": "Backlog",
|
||||
"todo": "Todo",
|
||||
"in_progress": "In Progress",
|
||||
"architecture": "Architecture",
|
||||
"development": "Development",
|
||||
"review": "Review",
|
||||
"testing": "Testing",
|
||||
"approved": "Approved",
|
||||
"rejected": "Rejected",
|
||||
"done": "Done",
|
||||
"cancelled": "Cancelled",
|
||||
"needs_input": "Needs Input",
|
||||
"in_review": "In Review",
|
||||
"blocked": "Blocked",
|
||||
}.items()}
|
||||
logical_to_plane = {
|
||||
"backlog": "Backlog",
|
||||
"todo": "Todo",
|
||||
"in_progress": "In Progress",
|
||||
"architecture": "Architecture",
|
||||
"development": "Development",
|
||||
"review": "Review",
|
||||
"testing": "Testing",
|
||||
"approved": "Approved",
|
||||
"rejected": "Rejected",
|
||||
"done": "Done",
|
||||
"cancelled": "Cancelled",
|
||||
"needs_input": "Needs Input",
|
||||
"in_review": "In Review",
|
||||
"blocked": "Blocked",
|
||||
}
|
||||
results = [
|
||||
{"id": uid, "name": logical_to_plane[key]}
|
||||
for key, uid in states_dict.items()
|
||||
if key in logical_to_plane
|
||||
]
|
||||
return {"results": results}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers to build fake httpx responses.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _fake_response(data: dict, status: int = 200):
|
||||
m = MagicMock()
|
||||
m.status_code = status
|
||||
m.json.return_value = data
|
||||
if status >= 400:
|
||||
from httpx import HTTPStatusError, Request, Response
|
||||
m.raise_for_status.side_effect = HTTPStatusError(
|
||||
"error", request=MagicMock(), response=MagicMock()
|
||||
)
|
||||
else:
|
||||
m.raise_for_status.return_value = None
|
||||
return m
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_states_cache():
|
||||
"""Ensure the states cache is empty before each test."""
|
||||
import src.plane_sync as ps
|
||||
ps.reload_project_states()
|
||||
yield
|
||||
ps.reload_project_states()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1 & 2. get_project_states returns correct UUIDs per project
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_get_project_states_enduro():
|
||||
"""ET project -> enduro-trails UUIDs."""
|
||||
import src.plane_sync as ps
|
||||
with patch("src.plane_sync.httpx.get") as mock_get:
|
||||
mock_get.return_value = _fake_response(_make_states_response(ET_STATES))
|
||||
states = ps.get_project_states(ET_PROJECT_ID)
|
||||
|
||||
for key, expected_uuid in ET_STATES.items():
|
||||
assert states[key] == expected_uuid, (
|
||||
f"ET state '{key}': expected {expected_uuid}, got {states.get(key)}"
|
||||
)
|
||||
|
||||
|
||||
def test_get_project_states_orchestrator():
|
||||
"""ORCH project -> orchestrator UUIDs."""
|
||||
import src.plane_sync as ps
|
||||
with patch("src.plane_sync.httpx.get") as mock_get:
|
||||
mock_get.return_value = _fake_response(_make_states_response(ORCH_STATES))
|
||||
states = ps.get_project_states(ORCH_PROJECT_ID)
|
||||
|
||||
for key, expected_uuid in ORCH_STATES.items():
|
||||
assert states[key] == expected_uuid, (
|
||||
f"ORCH state '{key}': expected {expected_uuid}, got {states.get(key)}"
|
||||
)
|
||||
|
||||
|
||||
def test_get_project_states_et_in_progress_uuid():
|
||||
"""ET in_progress == b873d9eb (exact UUID from ТЗ)."""
|
||||
import src.plane_sync as ps
|
||||
with patch("src.plane_sync.httpx.get") as mock_get:
|
||||
mock_get.return_value = _fake_response(_make_states_response(ET_STATES))
|
||||
states = ps.get_project_states(ET_PROJECT_ID)
|
||||
assert states["in_progress"] == "b873d9eb-993c-48cd-97ac-99a9b1623967"
|
||||
|
||||
|
||||
def test_get_project_states_orch_in_progress_uuid():
|
||||
"""ORCH in_progress == e331bfb3 (exact UUID from ТЗ) — the ORCH-10 blocker."""
|
||||
import src.plane_sync as ps
|
||||
with patch("src.plane_sync.httpx.get") as mock_get:
|
||||
mock_get.return_value = _fake_response(_make_states_response(ORCH_STATES))
|
||||
states = ps.get_project_states(ORCH_PROJECT_ID)
|
||||
assert states["in_progress"] == "e331bfb3-e17e-4699-ba48-4abb89c21b7b"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Fallback to _DEFAULT_STATES when API fails
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_get_project_states_api_error_fallback():
|
||||
"""Network failure -> returns _DEFAULT_STATES (ET values)."""
|
||||
import src.plane_sync as ps
|
||||
with patch("src.plane_sync.httpx.get", side_effect=Exception("network error")):
|
||||
states = ps.get_project_states(ORCH_PROJECT_ID)
|
||||
# Should return _DEFAULT_STATES (ET values) as fallback.
|
||||
assert states is ps._DEFAULT_STATES
|
||||
|
||||
|
||||
def test_get_project_states_non_200_fallback():
|
||||
"""Non-2xx response -> returns _DEFAULT_STATES."""
|
||||
import src.plane_sync as ps
|
||||
with patch("src.plane_sync.httpx.get") as mock_get:
|
||||
mock_get.return_value = _fake_response({}, status=500)
|
||||
states = ps.get_project_states(ORCH_PROJECT_ID)
|
||||
assert states is ps._DEFAULT_STATES
|
||||
|
||||
|
||||
def test_get_project_states_empty_response_fallback():
|
||||
"""Empty results list -> returns _DEFAULT_STATES."""
|
||||
import src.plane_sync as ps
|
||||
with patch("src.plane_sync.httpx.get") as mock_get:
|
||||
mock_get.return_value = _fake_response({"results": []})
|
||||
states = ps.get_project_states(ORCH_PROJECT_ID)
|
||||
assert states is ps._DEFAULT_STATES
|
||||
|
||||
|
||||
def test_get_project_states_none_project_id_fallback():
|
||||
"""None project_id -> _DEFAULT_STATES immediately (no API call)."""
|
||||
import src.plane_sync as ps
|
||||
with patch("src.plane_sync.httpx.get") as mock_get:
|
||||
states = ps.get_project_states(None)
|
||||
mock_get.assert_not_called()
|
||||
assert states is ps._DEFAULT_STATES
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Caching & reload_project_states
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_get_project_states_caches_result():
|
||||
"""Second call returns cached result without hitting the API again."""
|
||||
import src.plane_sync as ps
|
||||
with patch("src.plane_sync.httpx.get") as mock_get:
|
||||
mock_get.return_value = _fake_response(_make_states_response(ET_STATES))
|
||||
_ = ps.get_project_states(ET_PROJECT_ID)
|
||||
_ = ps.get_project_states(ET_PROJECT_ID)
|
||||
assert mock_get.call_count == 1
|
||||
|
||||
|
||||
def test_reload_project_states_per_project():
|
||||
"""reload_project_states(project_id) evicts only that project."""
|
||||
import src.plane_sync as ps
|
||||
with patch("src.plane_sync.httpx.get") as mock_get:
|
||||
mock_get.return_value = _fake_response(_make_states_response(ET_STATES))
|
||||
ps.get_project_states(ET_PROJECT_ID)
|
||||
assert ET_PROJECT_ID in ps._STATES_CACHE
|
||||
|
||||
ps.reload_project_states(ET_PROJECT_ID)
|
||||
assert ET_PROJECT_ID not in ps._STATES_CACHE
|
||||
|
||||
|
||||
def test_reload_project_states_full_flush():
|
||||
"""reload_project_states() with no args clears entire cache."""
|
||||
import src.plane_sync as ps
|
||||
with patch("src.plane_sync.httpx.get") as mock_get:
|
||||
mock_get.return_value = _fake_response(_make_states_response(ET_STATES))
|
||||
ps.get_project_states(ET_PROJECT_ID)
|
||||
ps.reload_project_states()
|
||||
assert ps._STATES_CACHE == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. stage_to_state() resolves per-project
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_stage_to_state_et_analysis():
|
||||
"""ET analysis -> in_progress UUID b873d9eb."""
|
||||
import src.plane_sync as ps
|
||||
with patch("src.plane_sync.httpx.get") as mock_get:
|
||||
mock_get.return_value = _fake_response(_make_states_response(ET_STATES))
|
||||
uid = ps.stage_to_state("analysis", ET_PROJECT_ID)
|
||||
assert uid == "b873d9eb-993c-48cd-97ac-99a9b1623967"
|
||||
|
||||
|
||||
def test_stage_to_state_orch_analysis():
|
||||
"""ORCH analysis -> in_progress UUID e331bfb3."""
|
||||
import src.plane_sync as ps
|
||||
with patch("src.plane_sync.httpx.get") as mock_get:
|
||||
mock_get.return_value = _fake_response(_make_states_response(ORCH_STATES))
|
||||
uid = ps.stage_to_state("analysis", ORCH_PROJECT_ID)
|
||||
assert uid == "e331bfb3-e17e-4699-ba48-4abb89c21b7b"
|
||||
|
||||
|
||||
def test_stage_to_state_unknown_stage():
|
||||
"""Unknown stage -> None."""
|
||||
import src.plane_sync as ps
|
||||
with patch("src.plane_sync.httpx.get") as mock_get:
|
||||
mock_get.return_value = _fake_response(_make_states_response(ET_STATES))
|
||||
uid = ps.stage_to_state("nonexistent_stage", ET_PROJECT_ID)
|
||||
assert uid is None
|
||||
|
||||
|
||||
def test_stage_to_state_orch_done():
|
||||
"""ORCH done -> 3738cd3c."""
|
||||
import src.plane_sync as ps
|
||||
with patch("src.plane_sync.httpx.get") as mock_get:
|
||||
mock_get.return_value = _fake_response(_make_states_response(ORCH_STATES))
|
||||
uid = ps.stage_to_state("done", ORCH_PROJECT_ID)
|
||||
assert uid == "3738cd3c-7610-4907-ba5e-26b9a248d9c0"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6 & 7. Webhook handle_issue_updated — ORCH-10 critical path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webhook_in_progress_et_starts_pipeline():
|
||||
"""ET In Progress (b873d9eb) -> handle_status_start called."""
|
||||
from src.webhooks.plane import handle_issue_updated
|
||||
import src.plane_sync as ps
|
||||
|
||||
et_states_resp = _make_states_response(ET_STATES)
|
||||
with patch("src.plane_sync.httpx.get") as mock_httpx, \
|
||||
patch("src.webhooks.plane.handle_status_start", new_callable=AsyncMock) as mock_start, \
|
||||
patch("src.webhooks.plane.handle_verdict", new_callable=AsyncMock) as mock_verdict:
|
||||
mock_httpx.return_value = _fake_response(et_states_resp)
|
||||
data = {
|
||||
"id": "et-issue-uuid",
|
||||
"state": {"id": "b873d9eb-993c-48cd-97ac-99a9b1623967", "name": "In Progress"},
|
||||
}
|
||||
await handle_issue_updated(data, ET_PROJECT_ID)
|
||||
|
||||
mock_start.assert_called_once()
|
||||
mock_verdict.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webhook_in_progress_orch_starts_pipeline():
|
||||
"""ORCH In Progress (e331bfb3) -> handle_status_start called.
|
||||
|
||||
This is the ORCH-10 blocker: previously the webhook compared against the
|
||||
hardcoded ET UUID (b873d9eb) and the ORCH UUID (e331bfb3) was silently
|
||||
ignored — the pipeline never started for ORCH tasks.
|
||||
"""
|
||||
from src.webhooks.plane import handle_issue_updated
|
||||
import src.plane_sync as ps
|
||||
|
||||
orch_states_resp = _make_states_response(ORCH_STATES)
|
||||
with patch("src.plane_sync.httpx.get") as mock_httpx, \
|
||||
patch("src.webhooks.plane.handle_status_start", new_callable=AsyncMock) as mock_start, \
|
||||
patch("src.webhooks.plane.handle_verdict", new_callable=AsyncMock) as mock_verdict:
|
||||
mock_httpx.return_value = _fake_response(orch_states_resp)
|
||||
data = {
|
||||
"id": "orch-issue-uuid",
|
||||
"state": {"id": "e331bfb3-e17e-4699-ba48-4abb89c21b7b", "name": "In Progress"},
|
||||
}
|
||||
await handle_issue_updated(data, ORCH_PROJECT_ID)
|
||||
|
||||
mock_start.assert_called_once()
|
||||
mock_verdict.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webhook_approved_orch():
|
||||
"""ORCH Approved (63f2c8fe) -> handle_verdict(approved=True)."""
|
||||
from src.webhooks.plane import handle_issue_updated
|
||||
orch_states_resp = _make_states_response(ORCH_STATES)
|
||||
with patch("src.plane_sync.httpx.get") as mock_httpx, \
|
||||
patch("src.webhooks.plane.handle_status_start", new_callable=AsyncMock) as mock_start, \
|
||||
patch("src.webhooks.plane.handle_verdict", new_callable=AsyncMock) as mock_verdict:
|
||||
mock_httpx.return_value = _fake_response(orch_states_resp)
|
||||
data = {
|
||||
"id": "orch-issue-uuid",
|
||||
"state": {"id": "63f2c8fe-dcda-4ace-952f-dd88bd0118ff", "name": "Approved"},
|
||||
}
|
||||
await handle_issue_updated(data, ORCH_PROJECT_ID)
|
||||
|
||||
mock_verdict.assert_called_once_with(data, ORCH_PROJECT_ID, approved=True)
|
||||
mock_start.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webhook_rejected_orch():
|
||||
"""ORCH Rejected (4c769e90) -> handle_verdict(approved=False)."""
|
||||
from src.webhooks.plane import handle_issue_updated
|
||||
orch_states_resp = _make_states_response(ORCH_STATES)
|
||||
with patch("src.plane_sync.httpx.get") as mock_httpx, \
|
||||
patch("src.webhooks.plane.handle_status_start", new_callable=AsyncMock) as mock_start, \
|
||||
patch("src.webhooks.plane.handle_verdict", new_callable=AsyncMock) as mock_verdict:
|
||||
mock_httpx.return_value = _fake_response(orch_states_resp)
|
||||
data = {
|
||||
"id": "orch-issue-uuid",
|
||||
"state": {"id": "4c769e90-bf80-4a52-b97a-e1c84904bfc3", "name": "Rejected"},
|
||||
}
|
||||
await handle_issue_updated(data, ORCH_PROJECT_ID)
|
||||
|
||||
mock_verdict.assert_called_once_with(data, ORCH_PROJECT_ID, approved=False)
|
||||
mock_start.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webhook_other_state_no_action():
|
||||
"""A non-trigger state (e.g. 'Needs Input') -> no pipeline action."""
|
||||
from src.webhooks.plane import handle_issue_updated
|
||||
orch_states_resp = _make_states_response(ORCH_STATES)
|
||||
with patch("src.plane_sync.httpx.get") as mock_httpx, \
|
||||
patch("src.webhooks.plane.handle_status_start", new_callable=AsyncMock) as mock_start, \
|
||||
patch("src.webhooks.plane.handle_verdict", new_callable=AsyncMock) as mock_verdict:
|
||||
mock_httpx.return_value = _fake_response(orch_states_resp)
|
||||
data = {
|
||||
"id": "orch-issue-uuid",
|
||||
"state": {"id": "99978b3f-72fe-46e3-8b9b-25ba02899fa0", "name": "Needs Input"},
|
||||
}
|
||||
await handle_issue_updated(data, ORCH_PROJECT_ID)
|
||||
|
||||
mock_start.assert_not_called()
|
||||
mock_verdict.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webhook_et_in_progress_not_confused_with_orch():
|
||||
"""ET In Progress UUID does NOT trigger pipeline for ORCH project.
|
||||
|
||||
This guards against the reverse confusion: if somehow an ET UUID was sent
|
||||
for an ORCH project event, it should NOT start the pipeline (wrong UUID).
|
||||
"""
|
||||
from src.webhooks.plane import handle_issue_updated
|
||||
orch_states_resp = _make_states_response(ORCH_STATES)
|
||||
with patch("src.plane_sync.httpx.get") as mock_httpx, \
|
||||
patch("src.webhooks.plane.handle_status_start", new_callable=AsyncMock) as mock_start, \
|
||||
patch("src.webhooks.plane.handle_verdict", new_callable=AsyncMock) as mock_verdict:
|
||||
mock_httpx.return_value = _fake_response(orch_states_resp)
|
||||
# Send ET's in_progress UUID for an ORCH project event.
|
||||
data = {
|
||||
"id": "orch-issue-uuid",
|
||||
"state": {"id": "b873d9eb-993c-48cd-97ac-99a9b1623967", "name": "In Progress"},
|
||||
}
|
||||
await handle_issue_updated(data, ORCH_PROJECT_ID)
|
||||
|
||||
# Since ORCH in_progress is e331bfb3, ET's b873d9eb should NOT trigger start.
|
||||
mock_start.assert_not_called()
|
||||
mock_verdict.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. _DEFAULT_STATES / PLANE_STATES alias preserved
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_plane_states_alias_is_default_states():
|
||||
"""PLANE_STATES is still exported and equals _DEFAULT_STATES (backward compat)."""
|
||||
import src.plane_sync as ps
|
||||
assert ps.PLANE_STATES is ps._DEFAULT_STATES
|
||||
|
||||
|
||||
def test_default_states_et_values():
|
||||
"""_DEFAULT_STATES contains the original enduro-trails UUIDs."""
|
||||
import src.plane_sync as ps
|
||||
for key, expected in ET_STATES.items():
|
||||
assert ps._DEFAULT_STATES[key] == expected, (
|
||||
f"_DEFAULT_STATES['{key}']: expected {expected}, got {ps._DEFAULT_STATES.get(key)}"
|
||||
)
|
||||
@@ -33,11 +33,36 @@ from src.main import app # noqa: E402
|
||||
from src.db import init_db, get_db # noqa: E402
|
||||
from src import projects as P # noqa: E402
|
||||
from src.projects import reload_projects # noqa: E402
|
||||
import src.plane_sync as plane_sync # noqa: E402
|
||||
|
||||
ORCH_PLANE_ID = "8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a"
|
||||
ENDURO_PLANE_ID = "7a79f0a9-5278-49cd-9007-9a338f238f9c"
|
||||
UNKNOWN_PLANE_ID = "deadbeef-0000-0000-0000-000000000000"
|
||||
|
||||
# ORCH-39: after ORCH-10 the webhook resolves Plane state UUIDs per-project via
|
||||
# get_project_states(project_id). Hardcoding the enduro in_progress UUID for an
|
||||
# ORCH-project payload no longer matches, so the pipeline never starts. We mock
|
||||
# get_project_states with a deterministic per-project map (no network) and send
|
||||
# each request with the UUID that matches its own project.
|
||||
_PROJECT_STATES = {
|
||||
ENDURO_PLANE_ID: {
|
||||
"in_progress": "b873d9eb-993c-48cd-97ac-99a9b1623967",
|
||||
"approved": "a519a341-dada-4a91-8910-7604f82b79c5",
|
||||
"rejected": "ba958f3c-5db5-461d-8f82-89425e413b97",
|
||||
},
|
||||
ORCH_PLANE_ID: {
|
||||
"in_progress": "e331bfb3-e17e-4699-ba48-4abb89c21b7b",
|
||||
"approved": "63f2c8fe-dcda-4ace-952f-dd88bd0118ff",
|
||||
"rejected": "4c769e90-bf80-4a52-b97a-e1c84904bfc3",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _fake_get_project_states(project_id):
|
||||
"""Deterministic per-project state map; mirrors get_project_states' fallback
|
||||
for unknown projects so the webhook still behaves sensibly."""
|
||||
return _PROJECT_STATES.get(project_id, _PROJECT_STATES[ENDURO_PLANE_ID])
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
@@ -57,6 +82,13 @@ def setup(monkeypatch):
|
||||
# focuses on the project filter, so bypass signature verification.
|
||||
monkeypatch.setattr("src.webhooks.plane.verify_plane_signature", lambda body, sig: True)
|
||||
|
||||
# ORCH-39: resolve Plane states deterministically per-project (no network)
|
||||
# and start from a clean per-project cache so suites don't leak into each
|
||||
# other. plane.py imports get_project_states locally from ..plane_sync, so
|
||||
# patch it at the src.plane_sync source.
|
||||
plane_sync.reload_project_states()
|
||||
monkeypatch.setattr(plane_sync, "get_project_states", _fake_get_project_states)
|
||||
|
||||
registry_json = (
|
||||
f'[{{"plane_project_id": "{ENDURO_PLANE_ID}", "repo": "enduro-trails",'
|
||||
f' "work_item_prefix": "ET", "name": "enduro-trails"}},'
|
||||
@@ -69,6 +101,7 @@ def setup(monkeypatch):
|
||||
yield
|
||||
|
||||
reload_projects() # restore from env
|
||||
plane_sync.reload_project_states()
|
||||
if os.path.exists(_test_db):
|
||||
os.unlink(_test_db)
|
||||
|
||||
@@ -76,10 +109,10 @@ def setup(monkeypatch):
|
||||
# Feature 1: the pipeline now starts on a status change to In Progress (not on
|
||||
# creation). _post_created drives that status-change event so these ORCH-6
|
||||
# routing tests still exercise task creation through the new trigger.
|
||||
_IN_PROGRESS = "b873d9eb-993c-48cd-97ac-99a9b1623967"
|
||||
|
||||
|
||||
# ORCH-39: the in_progress UUID is now project-specific, so derive it from the
|
||||
# project being posted to (matches get_project_states resolution above).
|
||||
def _post_created(plane_project_id, plane_id="wi-1", name="A valid work item title"):
|
||||
in_progress = _fake_get_project_states(plane_project_id)["in_progress"]
|
||||
return client.post(
|
||||
"/webhook/plane",
|
||||
json={
|
||||
@@ -90,7 +123,7 @@ def _post_created(plane_project_id, plane_id="wi-1", name="A valid work item tit
|
||||
"name": name,
|
||||
"description_stripped": "This is a sufficiently long description.",
|
||||
"project": plane_project_id,
|
||||
"state": {"id": _IN_PROGRESS, "name": "In Progress", "group": "started"},
|
||||
"state": {"id": in_progress, "name": "In Progress", "group": "started"},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
199
tests/test_post_usage_comments_integration.py
Normal file
199
tests/test_post_usage_comments_integration.py
Normal file
@@ -0,0 +1,199 @@
|
||||
"""ORCH-016 / TC-13..TC-15: _post_usage_comments integration tests.
|
||||
|
||||
End-to-end (DB + filesystem worktree, no network) verification that
|
||||
AgentLauncher._post_usage_comments:
|
||||
- resolves the task by (repo, branch),
|
||||
- threads the explicit duration_s into build_status_comment,
|
||||
- posts exactly ONE status comment authored by the finishing agent,
|
||||
- for deployer: ALSO posts the per-task usage summary (deployer authorship).
|
||||
|
||||
The actual Plane HTTP call (plane_sync.add_comment) is patched out; we only
|
||||
check the (work_item_id, body, author) tuples the launcher passes to it.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
|
||||
_test_db = os.path.join(tempfile.gettempdir(), "test_orch016_post_usage.db")
|
||||
os.environ["ORCH_DB_PATH"] = _test_db
|
||||
|
||||
import pytest # noqa: E402
|
||||
|
||||
from src import db as db_module # noqa: E402
|
||||
from src.db import init_db, get_db # noqa: E402
|
||||
from src.agents.launcher import AgentLauncher # noqa: E402
|
||||
|
||||
|
||||
REPO = "enduro-trails"
|
||||
BRANCH = "feature/ET-016-x"
|
||||
WID = "ET-016"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def setup_db(monkeypatch):
|
||||
monkeypatch.setattr(db_module.settings, "db_path", _test_db, raising=False)
|
||||
if os.path.exists(_test_db):
|
||||
os.unlink(_test_db)
|
||||
init_db()
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"INSERT INTO tasks (id, repo, branch, stage, work_item_id) "
|
||||
"VALUES (1, ?, ?, 'review', ?)",
|
||||
(REPO, BRANCH, WID),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
yield
|
||||
if os.path.exists(_test_db):
|
||||
os.unlink(_test_db)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_worktree(monkeypatch, tmp_path):
|
||||
"""Stub get_worktree_path inside the launcher module to a tmp_path location."""
|
||||
wt = tmp_path / "wt"
|
||||
(wt / "docs" / "work-items" / WID).mkdir(parents=True)
|
||||
|
||||
def _get_wt(repo, branch):
|
||||
return str(wt)
|
||||
|
||||
# The launcher imports get_worktree_path lazily inside the function body
|
||||
# (`from ..git_worktree import get_worktree_path`); patch the source module.
|
||||
monkeypatch.setattr("src.git_worktree.get_worktree_path", _get_wt)
|
||||
monkeypatch.setattr("src.usage._input_total", lambda u: 0) # quiet <sub> tail
|
||||
return wt
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def capture_comments(monkeypatch):
|
||||
posts = []
|
||||
|
||||
def _spy(work_item_id, body, author=None, **kwargs):
|
||||
posts.append({"wid": work_item_id, "body": body, "author": author})
|
||||
|
||||
monkeypatch.setattr("src.agents.launcher.plane_add_comment", _spy)
|
||||
return posts
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def public_url(monkeypatch):
|
||||
from src.config import settings
|
||||
monkeypatch.setattr(
|
||||
settings, "gitea_public_url", "https://git.mva154.duckdns.org", raising=False
|
||||
)
|
||||
monkeypatch.setattr(settings, "gitea_url", "http://localhost:3000", raising=False)
|
||||
monkeypatch.setattr(settings, "gitea_owner", "admin", raising=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-13: reviewer comment.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc13_reviewer_posts_one_status_comment(
|
||||
setup_db, fake_worktree, capture_comments, public_url
|
||||
):
|
||||
(fake_worktree / "docs" / "work-items" / WID / "12-review.md").write_text(
|
||||
"---\nverdict: APPROVE\n---\nReviewed.",
|
||||
)
|
||||
|
||||
AgentLauncher()._post_usage_comments(
|
||||
run_id=99, agent="reviewer", repo=REPO, branch=BRANCH,
|
||||
usage={"input_tokens": 1, "output_tokens": 1, "cost_usd": 0.01},
|
||||
duration_s=180,
|
||||
)
|
||||
|
||||
assert len(capture_comments) == 1
|
||||
post = capture_comments[0]
|
||||
assert post["wid"] == WID
|
||||
assert post["author"] == "reviewer"
|
||||
body = post["body"]
|
||||
assert "\U0001f50e Reviewer" in body
|
||||
assert "Verdict: APPROVE" in body
|
||||
assert "Длительность: 3m 00s" in body
|
||||
assert "12-review.md" in body
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-14: tester comment.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc14_tester_posts_one_status_comment(
|
||||
setup_db, fake_worktree, capture_comments, public_url
|
||||
):
|
||||
(fake_worktree / "docs" / "work-items" / WID / "13-test-report.md").write_text(
|
||||
"---\nverdict: PASS\n---\n",
|
||||
)
|
||||
|
||||
AgentLauncher()._post_usage_comments(
|
||||
run_id=100, agent="tester", repo=REPO, branch=BRANCH,
|
||||
usage=None, duration_s=42,
|
||||
)
|
||||
|
||||
assert len(capture_comments) == 1
|
||||
post = capture_comments[0]
|
||||
assert post["author"] == "tester"
|
||||
body = post["body"]
|
||||
assert "\U0001f9ea Tester" in body
|
||||
assert "Verdict: PASS" in body
|
||||
assert "Длительность: 42s" in body
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-15: deployer comment + per-task summary (two comments, both from deployer).
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc15_deployer_posts_status_then_summary(
|
||||
setup_db, fake_worktree, capture_comments, public_url
|
||||
):
|
||||
# Task stage = 'deploy' so build_status_comment uses 14-deploy-log.md.
|
||||
conn = get_db()
|
||||
conn.execute("UPDATE tasks SET stage='deploy' WHERE id=1")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
(fake_worktree / "docs" / "work-items" / WID / "14-deploy-log.md").write_text(
|
||||
"---\ndeploy_status: SUCCESS\n---\nDeployed.",
|
||||
)
|
||||
|
||||
AgentLauncher()._post_usage_comments(
|
||||
run_id=101, agent="deployer", repo=REPO, branch=BRANCH,
|
||||
usage={"input_tokens": 1, "output_tokens": 1, "cost_usd": 0.01},
|
||||
duration_s=300,
|
||||
)
|
||||
|
||||
# 2 comments: status + per-task summary.
|
||||
assert len(capture_comments) == 2
|
||||
status, summary = capture_comments
|
||||
assert status["author"] == "deployer"
|
||||
assert "Status: SUCCESS" in status["body"]
|
||||
assert "Длительность: 5m 00s" in status["body"]
|
||||
assert "14-deploy-log.md" in status["body"]
|
||||
|
||||
assert summary["author"] == "deployer"
|
||||
# task_summary_comment header (Russian "Итого по задаче").
|
||||
assert "\U0001f4ca" in summary["body"]
|
||||
assert "Итого" in summary["body"]
|
||||
|
||||
|
||||
def test_deployer_staging_picks_15_log(
|
||||
setup_db, fake_worktree, capture_comments, public_url
|
||||
):
|
||||
conn = get_db()
|
||||
conn.execute("UPDATE tasks SET stage='deploy-staging' WHERE id=1")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
(fake_worktree / "docs" / "work-items" / WID / "15-staging-log.md").write_text(
|
||||
"---\nstaging_status: SUCCESS\n---\n",
|
||||
)
|
||||
|
||||
AgentLauncher()._post_usage_comments(
|
||||
run_id=102, agent="deployer", repo=REPO, branch=BRANCH,
|
||||
usage=None, duration_s=10,
|
||||
)
|
||||
|
||||
# deployer always also posts the summary; check the FIRST comment is status.
|
||||
assert len(capture_comments) == 2
|
||||
status = capture_comments[0]
|
||||
assert "Status: SUCCESS" in status["body"]
|
||||
assert "15-staging-log.md" in status["body"]
|
||||
assert "14-deploy-log.md" not in status["body"]
|
||||
assert "staging-деплой" in status["body"]
|
||||
64
tests/test_qg_registry_snapshot.py
Normal file
64
tests/test_qg_registry_snapshot.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""ORCH-016 / TC-20 + AC-11: Quality Gates + stage machine are unchanged.
|
||||
|
||||
Smoke / change-detector test: the ORCH-016 PR touches comment formatting only.
|
||||
The QG registry (src/qg/checks.QG_CHECKS) and the stage-machine table
|
||||
(src/stages.STAGE_TRANSITIONS) MUST remain bit-identical to the contracts the
|
||||
pipeline depends on. If a future change moves the comment hot path into these
|
||||
files by accident, this guard breaks first.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
|
||||
from src.qg.checks import QG_CHECKS # noqa: E402
|
||||
from src.stages import STAGE_TRANSITIONS # noqa: E402
|
||||
|
||||
|
||||
# The set of QG names the pipeline DEPLOYS on. Order doesn't matter, identity does.
|
||||
_EXPECTED_QGS = {
|
||||
"check_analysis_approved",
|
||||
"check_analysis_complete",
|
||||
"check_architecture_done",
|
||||
"check_ci_green",
|
||||
"check_review_approved",
|
||||
"check_tests_passed",
|
||||
"check_reviewer_verdict",
|
||||
"check_tests_local",
|
||||
"check_deploy_status",
|
||||
"check_staging_status",
|
||||
}
|
||||
|
||||
|
||||
def test_tc20_qg_registry_unchanged():
|
||||
assert set(QG_CHECKS.keys()) == _EXPECTED_QGS
|
||||
|
||||
|
||||
def test_tc20_qg_callables_unchanged():
|
||||
# All entries must be callable — no stub / lambda / None.
|
||||
for name, fn in QG_CHECKS.items():
|
||||
assert callable(fn), f"QG {name} is not callable"
|
||||
|
||||
|
||||
# Reference snapshot of STAGE_TRANSITIONS (mirrors what's in docs/architecture
|
||||
# and src/stages.py — duplicated here on purpose as a regression yardstick).
|
||||
_EXPECTED_TRANSITIONS = {
|
||||
"created": {"next": "analysis", "agent": "analyst", "qg": None},
|
||||
"analysis": {"next": "architecture", "agent": "architect", "qg": "check_analysis_approved"},
|
||||
"architecture": {"next": "development", "agent": "developer", "qg": "check_architecture_done"},
|
||||
"development": {"next": "review", "agent": "reviewer", "qg": "check_ci_green"},
|
||||
"review": {"next": "testing", "agent": "tester", "qg": "check_reviewer_verdict"},
|
||||
"testing": {"next": "deploy-staging", "agent": "deployer", "qg": "check_tests_passed"},
|
||||
"deploy-staging": {"next": "deploy", "agent": "deployer", "qg": "check_staging_status"},
|
||||
"deploy": {"next": "done", "agent": None, "qg": "check_deploy_status"},
|
||||
"done": {"next": None, "agent": None, "qg": None},
|
||||
}
|
||||
|
||||
|
||||
def test_tc20_stage_transitions_unchanged():
|
||||
assert STAGE_TRANSITIONS == _EXPECTED_TRANSITIONS, (
|
||||
"STAGE_TRANSITIONS drift detected — ORCH-016 must not change the "
|
||||
"stage machine. Touched stage_engine or stages.py? Update the snapshot "
|
||||
"in a separate, intentional PR."
|
||||
)
|
||||
138
tests/test_resolve_agent_effort.py
Normal file
138
tests/test_resolve_agent_effort.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""ORCH-41: tests for resolve_agent_effort + effort validation + flag assembly.
|
||||
|
||||
Mirrors test_resolve_agent_model's 4-level priority for the --effort lever, and
|
||||
adds:
|
||||
- validation: a value outside {low,medium,high,xhigh,max} is dropped -> ""
|
||||
- flag assembly: --model / --effort / --fallback-model are present/absent in
|
||||
the built command exactly when the resolved value is non-empty.
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("ORCH_DB_PATH",
|
||||
os.path.join(tempfile.gettempdir(), "test_orch41_effort.db"))
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
|
||||
from src.agents.launcher import (
|
||||
resolve_agent_effort, resolve_agent_model, VALID_EFFORTS,
|
||||
)
|
||||
from src.config import settings
|
||||
from src import projects as P
|
||||
from src.projects import ProjectConfig, reload_projects
|
||||
|
||||
ORCH_PLANE_ID = "8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_settings(monkeypatch):
|
||||
monkeypatch.setattr(settings, "agent_effort_default", "high")
|
||||
for a in ("analyst", "architect", "developer", "reviewer"):
|
||||
monkeypatch.setattr(settings, f"agent_effort_{a}", "high")
|
||||
for a in ("tester", "deployer"):
|
||||
monkeypatch.setattr(settings, f"agent_effort_{a}", "medium")
|
||||
monkeypatch.setattr(P.settings, "projects_json", "")
|
||||
reload_projects()
|
||||
yield
|
||||
reload_projects()
|
||||
|
||||
|
||||
def _install_registry(monkeypatch, agent_efforts):
|
||||
reg = [ProjectConfig(
|
||||
plane_project_id=ORCH_PLANE_ID, repo="orchestrator",
|
||||
work_item_prefix="ORCH", name="orchestrator",
|
||||
agent_efforts=agent_efforts,
|
||||
)]
|
||||
monkeypatch.setattr(P, "PROJECTS", reg)
|
||||
monkeypatch.setattr(P, "_BY_PLANE_ID", {p.plane_project_id: p for p in reg})
|
||||
monkeypatch.setattr(P, "_BY_REPO", {p.repo: p for p in reg})
|
||||
|
||||
|
||||
# ---- default split ----------------------------------------------------------
|
||||
def test_default_split():
|
||||
assert resolve_agent_effort("developer") == "high"
|
||||
assert resolve_agent_effort("architect") == "high"
|
||||
assert resolve_agent_effort("tester") == "medium"
|
||||
assert resolve_agent_effort("deployer") == "medium"
|
||||
|
||||
|
||||
# ---- level 4: nothing -> "" -------------------------------------------------
|
||||
def test_no_config_returns_empty(monkeypatch):
|
||||
monkeypatch.setattr(settings, "agent_effort_default", "")
|
||||
monkeypatch.setattr(settings, "agent_effort_tester", "")
|
||||
assert resolve_agent_effort("tester") == ""
|
||||
|
||||
|
||||
# ---- level 2: per-agent env beats default -----------------------------------
|
||||
def test_per_agent_env(monkeypatch):
|
||||
monkeypatch.setattr(settings, "agent_effort_tester", "low")
|
||||
assert resolve_agent_effort("tester") == "low"
|
||||
|
||||
|
||||
# ---- level 1: project override wins -----------------------------------------
|
||||
def test_project_override(monkeypatch):
|
||||
monkeypatch.setattr(settings, "agent_effort_developer", "high")
|
||||
_install_registry(monkeypatch, {"developer": "xhigh"})
|
||||
assert resolve_agent_effort("developer", ORCH_PLANE_ID) == "xhigh"
|
||||
assert resolve_agent_effort("developer") == "high"
|
||||
|
||||
|
||||
# ---- validation: invalid value dropped --------------------------------------
|
||||
def test_invalid_default_dropped(monkeypatch):
|
||||
monkeypatch.setattr(settings, "agent_effort_developer", "")
|
||||
monkeypatch.setattr(settings, "agent_effort_default", "turbo")
|
||||
assert resolve_agent_effort("developer") == ""
|
||||
|
||||
|
||||
def test_invalid_env_dropped(monkeypatch):
|
||||
monkeypatch.setattr(settings, "agent_effort_reviewer", "ultra")
|
||||
assert resolve_agent_effort("reviewer") == ""
|
||||
|
||||
|
||||
def test_invalid_project_override_dropped(monkeypatch):
|
||||
_install_registry(monkeypatch, {"developer": "bogus"})
|
||||
assert resolve_agent_effort("developer", ORCH_PLANE_ID) == ""
|
||||
|
||||
|
||||
def test_all_valid_efforts_pass(monkeypatch):
|
||||
monkeypatch.setattr(settings, "agent_effort_developer", "")
|
||||
for e in VALID_EFFORTS:
|
||||
monkeypatch.setattr(settings, "agent_effort_default", e)
|
||||
assert resolve_agent_effort("developer") == e
|
||||
|
||||
|
||||
# ---- flag assembly (mirror of launcher cmd construction) --------------------
|
||||
def _build_flags(model, effort, fb):
|
||||
model_flag = f"--model {model} " if model else ""
|
||||
effort_flag = f"--effort {effort} " if effort else ""
|
||||
fb_flag = f"--fallback-model {fb} " if fb else ""
|
||||
return f"{model_flag}{effort_flag}{fb_flag}"
|
||||
|
||||
|
||||
def test_flags_present_when_configured(monkeypatch):
|
||||
monkeypatch.setattr(settings, "agent_fallback_model", "claude-sonnet-4-6")
|
||||
model = resolve_agent_model("developer")
|
||||
effort = resolve_agent_effort("developer")
|
||||
fb = settings.agent_fallback_model
|
||||
flags = _build_flags(model, effort, fb)
|
||||
assert "--model claude-opus-4-8 " in flags
|
||||
assert "--effort high " in flags
|
||||
assert "--fallback-model claude-sonnet-4-6 " in flags
|
||||
|
||||
|
||||
def test_flags_absent_when_empty(monkeypatch):
|
||||
monkeypatch.setattr(settings, "agent_model_default", "")
|
||||
monkeypatch.setattr(settings, "agent_model_developer", "")
|
||||
monkeypatch.setattr(settings, "agent_effort_default", "")
|
||||
monkeypatch.setattr(settings, "agent_effort_developer", "")
|
||||
monkeypatch.setattr(settings, "agent_fallback_model", "")
|
||||
model = resolve_agent_model("developer")
|
||||
effort = resolve_agent_effort("developer")
|
||||
fb = settings.agent_fallback_model
|
||||
flags = _build_flags(model, effort, fb)
|
||||
assert flags == ""
|
||||
assert "--model" not in flags
|
||||
assert "--effort" not in flags
|
||||
assert "--fallback-model" not in flags
|
||||
156
tests/test_resolve_agent_model.py
Normal file
156
tests/test_resolve_agent_model.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""ORCH-41: tests for resolve_agent_model (per-agent + per-project LLM model).
|
||||
|
||||
Covers the 4-level resolution priority:
|
||||
1. ProjectConfig.agent_models[agent] (per-project override, from projects_json)
|
||||
2. settings.agent_model_<agent> (per-agent env, when non-empty)
|
||||
3. settings.agent_model_default (global default)
|
||||
4. "" (no override anywhere -> CLI default)
|
||||
|
||||
plus: unknown project_id / no project_id skips level 1, unknown agent skips
|
||||
level 2, and the frozen ProjectConfig still accepts agent_models (default {}).
|
||||
|
||||
We never mutate the module-global registry permanently: tests that need a
|
||||
custom registry install one via monkeypatch + reload_projects and restore the
|
||||
default afterwards (autouse fixture).
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("ORCH_DB_PATH",
|
||||
os.path.join(tempfile.gettempdir(), "test_orch41_model.db"))
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
|
||||
from src.agents.launcher import resolve_agent_model
|
||||
from src.config import settings
|
||||
from src import projects as P
|
||||
from src.projects import ProjectConfig, reload_projects, _parse_projects_json
|
||||
|
||||
ORCH_PLANE_ID = "8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a"
|
||||
ENDURO_PLANE_ID = "7a79f0a9-5278-49cd-9007-9a338f238f9c"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_settings(monkeypatch):
|
||||
"""Reset all per-agent/default model settings to a known baseline so tests
|
||||
are order-independent regardless of what other modules set in the env."""
|
||||
monkeypatch.setattr(settings, "agent_model_default", "claude-opus-4-8")
|
||||
for a in ("analyst", "architect", "developer", "reviewer", "tester", "deployer"):
|
||||
monkeypatch.setattr(settings, f"agent_model_{a}", "")
|
||||
# default registry (no per-project overrides)
|
||||
monkeypatch.setattr(P.settings, "projects_json", "")
|
||||
reload_projects()
|
||||
yield
|
||||
reload_projects()
|
||||
|
||||
|
||||
def _install_registry(monkeypatch, agent_models):
|
||||
"""Install a single-project registry for ORCH with the given agent_models."""
|
||||
reg = [ProjectConfig(
|
||||
plane_project_id=ORCH_PLANE_ID, repo="orchestrator",
|
||||
work_item_prefix="ORCH", name="orchestrator",
|
||||
agent_models=agent_models,
|
||||
)]
|
||||
monkeypatch.setattr(P, "PROJECTS", reg)
|
||||
monkeypatch.setattr(P, "_BY_PLANE_ID", {p.plane_project_id: p for p in reg})
|
||||
monkeypatch.setattr(P, "_BY_REPO", {p.repo: p for p in reg})
|
||||
|
||||
|
||||
# ---- Level 4: nothing configured -> "" --------------------------------------
|
||||
def test_no_config_returns_empty(monkeypatch):
|
||||
monkeypatch.setattr(settings, "agent_model_default", "")
|
||||
assert resolve_agent_model("developer") == ""
|
||||
assert resolve_agent_model("developer", ORCH_PLANE_ID) == ""
|
||||
|
||||
|
||||
# ---- Level 3: global default ------------------------------------------------
|
||||
def test_global_default():
|
||||
assert resolve_agent_model("developer") == "claude-opus-4-8"
|
||||
assert resolve_agent_model("architect") == "claude-opus-4-8"
|
||||
|
||||
|
||||
# ---- Level 2: per-agent env beats default -----------------------------------
|
||||
def test_per_agent_env_overrides_default(monkeypatch):
|
||||
monkeypatch.setattr(settings, "agent_model_reviewer", "claude-sonnet-4-6")
|
||||
assert resolve_agent_model("reviewer") == "claude-sonnet-4-6"
|
||||
# other agents still fall through to default
|
||||
assert resolve_agent_model("developer") == "claude-opus-4-8"
|
||||
|
||||
|
||||
# ---- Level 1: per-project override beats per-agent env and default ----------
|
||||
def test_project_override_beats_env_and_default(monkeypatch):
|
||||
monkeypatch.setattr(settings, "agent_model_developer", "claude-sonnet-4-6")
|
||||
_install_registry(monkeypatch, {"developer": "claude-opus-4-8"})
|
||||
assert resolve_agent_model("developer", ORCH_PLANE_ID) == "claude-opus-4-8"
|
||||
# without project_id, falls back to per-agent env
|
||||
assert resolve_agent_model("developer") == "claude-sonnet-4-6"
|
||||
|
||||
|
||||
def test_project_override_only_for_listed_agent(monkeypatch):
|
||||
_install_registry(monkeypatch, {"developer": "claude-opus-4-8"})
|
||||
# reviewer not in agent_models -> falls back to default
|
||||
assert resolve_agent_model("reviewer", ORCH_PLANE_ID) == "claude-opus-4-8"
|
||||
monkeypatch.setattr(settings, "agent_model_reviewer", "claude-sonnet-4-6")
|
||||
assert resolve_agent_model("reviewer", ORCH_PLANE_ID) == "claude-sonnet-4-6"
|
||||
|
||||
|
||||
# ---- unknown / empty project id skips level 1 -------------------------------
|
||||
def test_unknown_project_id_skips_override(monkeypatch):
|
||||
_install_registry(monkeypatch, {"developer": "x-model"})
|
||||
assert resolve_agent_model("developer", "no-such-uuid") == "claude-opus-4-8"
|
||||
assert resolve_agent_model("developer", None) == "claude-opus-4-8"
|
||||
|
||||
|
||||
# ---- unknown agent skips per-agent env, still gets default ------------------
|
||||
def test_unknown_agent_falls_to_default():
|
||||
assert resolve_agent_model("nonexistent") == "claude-opus-4-8"
|
||||
|
||||
|
||||
# ---- frozen ProjectConfig accepts agent_models ------------------------------
|
||||
def test_projectconfig_frozen_with_agent_models():
|
||||
pc = ProjectConfig(
|
||||
plane_project_id="x", repo="r", work_item_prefix="P", name="n",
|
||||
agent_models={"developer": "m"},
|
||||
)
|
||||
assert pc.agent_models == {"developer": "m"}
|
||||
# default is an empty dict, not shared/mutable across instances
|
||||
pc2 = ProjectConfig(plane_project_id="y", repo="r2",
|
||||
work_item_prefix="P2", name="n2")
|
||||
assert pc2.agent_models == {}
|
||||
assert pc2.agent_models is not pc.agent_models
|
||||
with pytest.raises(Exception):
|
||||
pc.repo = "changed" # frozen
|
||||
|
||||
|
||||
# ---- projects_json parsing of agent_models / agent_efforts ------------------
|
||||
def test_parse_projects_json_with_overrides():
|
||||
raw = (
|
||||
'[{"plane_project_id":"p1","repo":"orchestrator",'
|
||||
'"work_item_prefix":"ORCH",'
|
||||
'"agent_models":{"developer":"claude-opus-4-8","reviewer":"claude-sonnet-4-6"},'
|
||||
'"agent_efforts":{"developer":"xhigh","tester":"low"}}]'
|
||||
)
|
||||
parsed = _parse_projects_json(raw)
|
||||
assert parsed is not None and len(parsed) == 1
|
||||
pc = parsed[0]
|
||||
assert pc.agent_models == {"developer": "claude-opus-4-8",
|
||||
"reviewer": "claude-sonnet-4-6"}
|
||||
assert pc.agent_efforts == {"developer": "xhigh", "tester": "low"}
|
||||
|
||||
|
||||
def test_parse_projects_json_omitted_overrides_default_empty():
|
||||
raw = ('[{"plane_project_id":"p1","repo":"r","work_item_prefix":"P"}]')
|
||||
parsed = _parse_projects_json(raw)
|
||||
assert parsed is not None and len(parsed) == 1
|
||||
assert parsed[0].agent_models == {}
|
||||
assert parsed[0].agent_efforts == {}
|
||||
|
||||
|
||||
def test_parse_projects_json_malformed_override_ignored():
|
||||
# agent_models is not an object -> dropped to {}, entry still valid
|
||||
raw = ('[{"plane_project_id":"p1","repo":"r","work_item_prefix":"P",'
|
||||
'"agent_models":"oops"}]')
|
||||
parsed = _parse_projects_json(raw)
|
||||
assert parsed is not None and parsed[0].agent_models == {}
|
||||
122
tests/test_status_comment_authorship.py
Normal file
122
tests/test_status_comment_authorship.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""ORCH-016 / TC-19 + AC-1..AC-5 authorship: status comments use per-agent bots.
|
||||
|
||||
When a status comment is posted by AgentLauncher._post_usage_comments, the
|
||||
underlying plane_sync.add_comment must be invoked with ``author=<agent>`` so
|
||||
plane_sync._headers_for(<agent>) picks the agent's bot token
|
||||
(PLANE_BOT_TOKENS[role]) — falling back to PLANE_HEADERS when the bot token
|
||||
is empty / role unknown. Comment FORMAT changes (ORCH-016) must not affect
|
||||
that authorship contract.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
|
||||
_test_db = os.path.join(tempfile.gettempdir(), "test_orch016_authorship.db")
|
||||
os.environ["ORCH_DB_PATH"] = _test_db
|
||||
|
||||
import pytest # noqa: E402
|
||||
|
||||
from src import db as db_module # noqa: E402
|
||||
from src.db import init_db, get_db # noqa: E402
|
||||
from src.agents.launcher import AgentLauncher # noqa: E402
|
||||
|
||||
REPO = "enduro-trails"
|
||||
BRANCH = "feature/ET-016-x"
|
||||
WID = "ET-016"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(monkeypatch):
|
||||
monkeypatch.setattr(db_module.settings, "db_path", _test_db, raising=False)
|
||||
if os.path.exists(_test_db):
|
||||
os.unlink(_test_db)
|
||||
init_db()
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"INSERT INTO tasks (id, repo, branch, stage, work_item_id) "
|
||||
"VALUES (1, ?, ?, 'review', ?)",
|
||||
(REPO, BRANCH, WID),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
yield
|
||||
if os.path.exists(_test_db):
|
||||
os.unlink(_test_db)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_wt(monkeypatch, tmp_path):
|
||||
base = tmp_path / "wt"
|
||||
(base / "docs" / "work-items" / WID).mkdir(parents=True)
|
||||
monkeypatch.setattr("src.git_worktree.get_worktree_path", lambda r, b: str(base))
|
||||
return base
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def capture(monkeypatch):
|
||||
posts = []
|
||||
|
||||
def _spy(work_item_id, body, author=None, **kwargs):
|
||||
posts.append({"wid": work_item_id, "body": body, "author": author})
|
||||
|
||||
monkeypatch.setattr("src.agents.launcher.plane_add_comment", _spy)
|
||||
return posts
|
||||
|
||||
|
||||
@pytest.mark.parametrize("agent", ["architect", "developer", "reviewer", "tester"])
|
||||
def test_tc19_status_comment_carries_agent_author(agent, db, fake_wt, capture):
|
||||
"""Each agent's status comment must be POST-ed under that agent's bot."""
|
||||
AgentLauncher()._post_usage_comments(
|
||||
run_id=1, agent=agent, repo=REPO, branch=BRANCH,
|
||||
usage=None, duration_s=10,
|
||||
)
|
||||
assert len(capture) >= 1
|
||||
assert capture[0]["author"] == agent, (
|
||||
f"Expected author={agent!r}, got {capture[0]['author']!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_tc19_deployer_status_and_summary_both_authored_by_deployer(db, fake_wt, capture):
|
||||
"""Deployer posts TWO comments (status + per-task summary) — both ``author='deployer'``."""
|
||||
conn = get_db()
|
||||
conn.execute("UPDATE tasks SET stage='deploy' WHERE id=1")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
AgentLauncher()._post_usage_comments(
|
||||
run_id=2, agent="deployer", repo=REPO, branch=BRANCH,
|
||||
usage=None, duration_s=10,
|
||||
)
|
||||
|
||||
assert len(capture) == 2
|
||||
assert {c["author"] for c in capture} == {"deployer"}
|
||||
|
||||
|
||||
def test_tc19_headers_for_unknown_role_falls_back(monkeypatch):
|
||||
"""Ensure plane_sync._headers_for handles unknown agents (fallback contract)."""
|
||||
from src import plane_sync
|
||||
h = plane_sync._headers_for("unknown_role_xyz")
|
||||
# PLANE_HEADERS fallback uses settings.plane_api_token (set to 'test-token').
|
||||
assert isinstance(h, dict) and "X-API-Key" in h
|
||||
|
||||
|
||||
def test_tc19_status_comment_format_preserves_author_contract(db, fake_wt, capture):
|
||||
"""The ORCH-016 format change must not strip the author= kw from the call site."""
|
||||
(fake_wt / "docs" / "work-items" / WID / "12-review.md").write_text(
|
||||
"---\nverdict: APPROVE\n---\n",
|
||||
)
|
||||
AgentLauncher()._post_usage_comments(
|
||||
run_id=3, agent="reviewer", repo=REPO, branch=BRANCH,
|
||||
usage={"input_tokens": 0, "output_tokens": 0, "cost_usd": 0.0},
|
||||
duration_s=180,
|
||||
)
|
||||
assert len(capture) == 1
|
||||
post = capture[0]
|
||||
assert post["author"] == "reviewer"
|
||||
# And the new format is present in the body (sanity).
|
||||
assert "\U0001f50e Reviewer" in post["body"]
|
||||
assert "Verdict: APPROVE" in post["body"]
|
||||
assert "Длительность: 3m 00s" in post["body"]
|
||||
124
tests/test_status_comment_dedup_regression.py
Normal file
124
tests/test_status_comment_dedup_regression.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""ORCH-016 / TC-17 + AC-7: status-comment de-dup contract.
|
||||
|
||||
The «one comment per agent per stage» guarantee is enforced upstream of
|
||||
build_status_comment by:
|
||||
- the webhook event-dedup table (events.delivery_id PARTIAL UNIQUE, ORCH-5 /
|
||||
src.db.insert_event_dedup),
|
||||
- the job queue claim-once contract (src.db.claim_next_job, ORCH-1).
|
||||
|
||||
The ORCH-016 PR introduces a new comment FORMAT but must not weaken these
|
||||
guarantees. This regression test:
|
||||
1. exercises insert_event_dedup directly to confirm the same delivery_id is
|
||||
accepted exactly once (sanity for the dedup primitive),
|
||||
2. exercises build_status_comment to confirm it is a PURE function (same
|
||||
inputs -> same output), so a retried call from a poorly-isolated test or a
|
||||
misbehaving caller doesn't silently produce two different comment bodies.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
|
||||
_test_db = os.path.join(tempfile.gettempdir(), "test_orch016_dedup_regression.db")
|
||||
os.environ["ORCH_DB_PATH"] = _test_db
|
||||
|
||||
import pytest # noqa: E402
|
||||
|
||||
from src import db as db_module # noqa: E402
|
||||
from src.db import init_db, insert_event_dedup # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_db(monkeypatch):
|
||||
monkeypatch.setattr(db_module.settings, "db_path", _test_db, raising=False)
|
||||
if os.path.exists(_test_db):
|
||||
os.unlink(_test_db)
|
||||
init_db()
|
||||
yield
|
||||
if os.path.exists(_test_db):
|
||||
os.unlink(_test_db)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Primitive: event-dedup still rejects a re-delivered webhook.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc17_event_dedup_inserts_once_for_same_delivery_id():
|
||||
"""Two webhook deliveries with the same delivery_id -> one row inserted.
|
||||
|
||||
First call returns True (new row); second call returns False (rejected).
|
||||
This is the primitive every status-comment trigger relies on.
|
||||
"""
|
||||
assert insert_event_dedup("plane", "issue.updated", "{}", "delivery-XYZ") is True
|
||||
assert insert_event_dedup("plane", "issue.updated", "{}", "delivery-XYZ") is False
|
||||
|
||||
|
||||
def test_tc17_event_dedup_distinguishes_delivery_ids():
|
||||
"""Distinct delivery IDs are independent — two different webhooks both go through."""
|
||||
assert insert_event_dedup("plane", "issue.updated", "{}", "delivery-A") is True
|
||||
assert insert_event_dedup("plane", "issue.updated", "{}", "delivery-B") is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Format: build_status_comment is deterministic. A double-fire from buggy code
|
||||
# still produces an IDENTICAL body -- so the upstream dedup primitive can
|
||||
# safely treat the second call as no-op without comparing prose.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc17_build_status_comment_is_pure(tmp_path):
|
||||
"""Same inputs produce byte-identical output (deterministic / side-effect free)."""
|
||||
from src import usage as U
|
||||
|
||||
wt = tmp_path / "wt"
|
||||
(wt / "docs" / "work-items" / "ET-016").mkdir(parents=True)
|
||||
(wt / "docs" / "work-items" / "ET-016" / "12-review.md").write_text(
|
||||
"---\nverdict: APPROVE\n---\n",
|
||||
)
|
||||
|
||||
args = dict(
|
||||
repo="enduro-trails",
|
||||
branch="feature/ET-016-x",
|
||||
work_item_id="ET-016",
|
||||
duration_s=120,
|
||||
worktree_root=str(wt),
|
||||
usage={"input_tokens": 100, "output_tokens": 50, "cost_usd": 0.05},
|
||||
)
|
||||
a = U.build_status_comment("reviewer", **args)
|
||||
b = U.build_status_comment("reviewer", **args)
|
||||
c = U.build_status_comment("reviewer", **args)
|
||||
|
||||
assert a == b == c
|
||||
|
||||
|
||||
def test_tc17_build_status_comment_no_db_side_effects(tmp_path):
|
||||
"""A status-comment build must NOT write to the DB.
|
||||
|
||||
Otherwise a webhook-dedup hit would still touch state via the comment
|
||||
builder. We check by counting rows in `tasks`/`agent_runs`/`jobs` before
|
||||
and after.
|
||||
"""
|
||||
from src import usage as U
|
||||
from src.db import get_db
|
||||
|
||||
conn = get_db()
|
||||
counts_before = [
|
||||
conn.execute("SELECT COUNT(*) FROM tasks").fetchone()[0],
|
||||
conn.execute("SELECT COUNT(*) FROM agent_runs").fetchone()[0],
|
||||
conn.execute("SELECT COUNT(*) FROM jobs").fetchone()[0],
|
||||
]
|
||||
conn.close()
|
||||
|
||||
U.build_status_comment(
|
||||
"developer", repo="enduro-trails", branch="b",
|
||||
work_item_id="ET-016", pr_number=1, duration_s=10,
|
||||
usage={"input_tokens": 1, "output_tokens": 1, "cost_usd": 0.01},
|
||||
)
|
||||
|
||||
conn = get_db()
|
||||
counts_after = [
|
||||
conn.execute("SELECT COUNT(*) FROM tasks").fetchone()[0],
|
||||
conn.execute("SELECT COUNT(*) FROM agent_runs").fetchone()[0],
|
||||
conn.execute("SELECT COUNT(*) FROM jobs").fetchone()[0],
|
||||
]
|
||||
conn.close()
|
||||
assert counts_before == counts_after
|
||||
145
tests/test_status_comment_duration_db_fallback.py
Normal file
145
tests/test_status_comment_duration_db_fallback.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""ORCH-016 / TC-24 + TC-25 + AC-14: DB fallback for the duration line.
|
||||
|
||||
When build_status_comment is called WITHOUT an explicit duration_s but with a
|
||||
task_id, it must:
|
||||
- read the last finished agent_runs row for (task_id, agent),
|
||||
- compute (julianday(finished_at) - julianday(started_at)) * 86400 in seconds,
|
||||
- format it via fmt_duration and inject the «Длительность: …» line.
|
||||
|
||||
Failure modes (DB locked / row missing / NULL finished_at / negative diff) must
|
||||
NEVER raise; they simply suppress the duration line and let the rest of the
|
||||
comment publish.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
|
||||
_test_db = os.path.join(tempfile.gettempdir(), "test_orch016_duration_fallback.db")
|
||||
os.environ["ORCH_DB_PATH"] = _test_db
|
||||
|
||||
import pytest # noqa: E402
|
||||
|
||||
from src import db as db_module # noqa: E402
|
||||
from src.db import init_db, get_db # noqa: E402
|
||||
from src import usage as U # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_db(monkeypatch):
|
||||
monkeypatch.setattr(db_module.settings, "db_path", _test_db, raising=False)
|
||||
if os.path.exists(_test_db):
|
||||
os.unlink(_test_db)
|
||||
init_db()
|
||||
yield
|
||||
if os.path.exists(_test_db):
|
||||
os.unlink(_test_db)
|
||||
|
||||
|
||||
def _insert_run(task_id, agent, *, seconds_ago_start=None, finished=True):
|
||||
"""Insert an agent_runs row with controllable timestamps."""
|
||||
conn = get_db()
|
||||
if seconds_ago_start is None:
|
||||
conn.execute(
|
||||
"INSERT INTO agent_runs (task_id, agent) VALUES (?, ?)",
|
||||
(task_id, agent),
|
||||
)
|
||||
else:
|
||||
if finished:
|
||||
conn.execute(
|
||||
"INSERT INTO agent_runs (task_id, agent, started_at, finished_at) "
|
||||
"VALUES (?, ?, datetime('now', ?), datetime('now'))",
|
||||
(task_id, agent, f"-{seconds_ago_start} seconds"),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"INSERT INTO agent_runs (task_id, agent, started_at) "
|
||||
"VALUES (?, ?, datetime('now', ?))",
|
||||
(task_id, agent, f"-{seconds_ago_start} seconds"),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-24: explicit duration_s missing -> DB lookup populates the line.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc24_fallback_reads_agent_runs_for_last_finished():
|
||||
_insert_run(7, "reviewer", seconds_ago_start=240)
|
||||
secs = U.get_agent_duration(7, "reviewer")
|
||||
# SQLite's julianday math can be off by a second on either side.
|
||||
assert secs is not None and abs(secs - 240) <= 1, secs
|
||||
|
||||
html = U.build_status_comment("reviewer", task_id=7)
|
||||
assert any(
|
||||
s in html for s in (
|
||||
"Длительность: 4m 00s",
|
||||
"Длительность: 4m 01s",
|
||||
"Длительность: 3m 59s",
|
||||
)
|
||||
), html
|
||||
|
||||
|
||||
def test_tc24_fallback_picks_last_run_when_multiple():
|
||||
_insert_run(11, "developer", seconds_ago_start=120)
|
||||
_insert_run(11, "developer", seconds_ago_start=10)
|
||||
secs = U.get_agent_duration(11, "developer")
|
||||
assert secs is not None and abs(secs - 10) <= 1, secs
|
||||
|
||||
|
||||
def test_tc24_no_row_returns_none():
|
||||
assert U.get_agent_duration(999, "tester") is None
|
||||
|
||||
|
||||
def test_tc24_finished_at_null_returns_none():
|
||||
_insert_run(13, "tester", seconds_ago_start=100, finished=False)
|
||||
assert U.get_agent_duration(13, "tester") is None
|
||||
|
||||
|
||||
def test_tc24_missing_args_returns_none():
|
||||
assert U.get_agent_duration(None, "tester") is None
|
||||
assert U.get_agent_duration(7, "") is None
|
||||
assert U.get_agent_duration(0, "tester") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-25: read failure -> logged at debug, NO exception, comment still ships.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc25_db_read_failure_no_raise(monkeypatch, caplog):
|
||||
"""A locked / broken DB must not crash the status comment hot path."""
|
||||
import logging
|
||||
|
||||
def _boom():
|
||||
raise RuntimeError("simulated DB outage")
|
||||
|
||||
monkeypatch.setattr(U, "get_db", _boom)
|
||||
with caplog.at_level(logging.DEBUG, logger="orchestrator.usage"):
|
||||
assert U.get_agent_duration(1, "developer") is None
|
||||
# build_status_comment must still publish (no duration line, no crash).
|
||||
html = U.build_status_comment("developer", task_id=1, repo="r", branch="b")
|
||||
assert "Длительность" not in html
|
||||
assert "\U0001f4bb Developer" in html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sanity: explicit duration_s wins over DB fallback (no SELECT at all).
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_explicit_duration_wins_over_db_fallback(monkeypatch):
|
||||
called = {"n": 0}
|
||||
real = U.get_agent_duration
|
||||
|
||||
def _spy(task_id, agent):
|
||||
called["n"] += 1
|
||||
return real(task_id, agent)
|
||||
|
||||
monkeypatch.setattr(U, "get_agent_duration", _spy)
|
||||
_insert_run(5, "architect", seconds_ago_start=300)
|
||||
|
||||
html = U.build_status_comment(
|
||||
"architect", task_id=5, duration_s=12, repo="r", branch="b",
|
||||
)
|
||||
assert "Длительность: 12s" in html
|
||||
# Explicit value supplied -> DB fallback is short-circuited.
|
||||
assert called["n"] == 0
|
||||
354
tests/test_status_comment_format.py
Normal file
354
tests/test_status_comment_format.py
Normal file
@@ -0,0 +1,354 @@
|
||||
"""ORCH-016 / TC-01..TC-10, TC-12, TC-23: unified status comment format.
|
||||
|
||||
Unit tests for src.usage.build_status_comment(...) — the single hot path for
|
||||
every agent's "I just finished a stage" comment in Plane (ADR-001).
|
||||
|
||||
Covers:
|
||||
* Header per agent (icon + role + description from AC-1..AC-5).
|
||||
* Verdict / Status line read from frontmatter (reviewer / tester / deployer).
|
||||
* Длительность line shown when duration_s is supplied; suppressed otherwise.
|
||||
* <a href="..."> link items per agent.
|
||||
* URL base picks gitea_public_url, falls back to gitea_url.
|
||||
* Graceful behaviour when files are missing / no frontmatter (AC-8).
|
||||
|
||||
No DB / no network — only the worktree filesystem (via tmp_path).
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
|
||||
import pytest # noqa: E402
|
||||
|
||||
from src import usage as U # noqa: E402
|
||||
|
||||
|
||||
WID = "ET-016"
|
||||
REPO = "enduro-trails"
|
||||
BRANCH = "feature/ET-016-status-comments"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _set_urls(monkeypatch):
|
||||
"""gitea_public_url is the canonical clickable base (AC-9)."""
|
||||
monkeypatch.setattr(U, "logger", U.logger)
|
||||
from src.config import settings
|
||||
monkeypatch.setattr(settings, "gitea_url", "http://localhost:3000", raising=False)
|
||||
monkeypatch.setattr(
|
||||
settings, "gitea_public_url", "https://git.mva154.duckdns.org", raising=False
|
||||
)
|
||||
monkeypatch.setattr(settings, "gitea_owner", "admin", raising=False)
|
||||
yield
|
||||
|
||||
|
||||
def _wt_with_files(tmp_path, files: dict) -> str:
|
||||
"""Create a worktree skeleton with given files. `files` maps rel-path -> body."""
|
||||
base = tmp_path / "wt"
|
||||
docs = base / "docs" / "work-items" / WID
|
||||
docs.mkdir(parents=True)
|
||||
for rel, body in files.items():
|
||||
p = docs / rel if not rel.startswith("/") else base / rel.lstrip("/")
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(body)
|
||||
return str(base)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-01: architect comment
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc01_architect_comment(tmp_path):
|
||||
wt = _wt_with_files(tmp_path, {"06-adr/ADR-001-x.md": "x"})
|
||||
|
||||
html = U.build_status_comment(
|
||||
"architect",
|
||||
repo=REPO, branch=BRANCH, work_item_id=WID,
|
||||
duration_s=312,
|
||||
worktree_root=wt,
|
||||
)
|
||||
# Header
|
||||
assert "\U0001f4d0 Architect — " in html, html
|
||||
assert "архитектурную" in html
|
||||
assert "См. ADR ниже" in html
|
||||
# Duration: 312s -> 5m 12s
|
||||
assert "Длительность: 5m 12s" in html
|
||||
# ADR link via gitea_public_url
|
||||
assert ("https://git.mva154.duckdns.org/admin/enduro-trails/src/branch/"
|
||||
f"{BRANCH}/docs/work-items/{WID}/06-adr") in html
|
||||
# No Verdict for architect
|
||||
assert "Verdict" not in html
|
||||
assert "Status:" not in html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-02: developer comment with PR + branch
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc02_developer_comment_links_branch_and_pr():
|
||||
html = U.build_status_comment(
|
||||
"developer",
|
||||
repo=REPO, branch=BRANCH, work_item_id=WID,
|
||||
pr_number=42, duration_s=600,
|
||||
)
|
||||
assert "\U0001f4bb Developer — " in html
|
||||
assert "разработку" in html
|
||||
# Both branch and PR links
|
||||
assert f"https://git.mva154.duckdns.org/admin/{REPO}/src/branch/{BRANCH}" in html
|
||||
assert f"https://git.mva154.duckdns.org/admin/{REPO}/pulls/42" in html
|
||||
assert f"PR #42" in html
|
||||
assert "Длительность: 10m 00s" in html
|
||||
assert "Verdict" not in html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-03 / TC-04: reviewer verdict via frontmatter
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc03_reviewer_verdict_approve(tmp_path):
|
||||
wt = _wt_with_files(tmp_path, {
|
||||
"12-review.md": "---\nverdict: APPROVE\n---\nbody...",
|
||||
})
|
||||
html = U.build_status_comment(
|
||||
"reviewer",
|
||||
repo=REPO, branch=BRANCH, work_item_id=WID,
|
||||
duration_s=120, worktree_root=wt,
|
||||
)
|
||||
assert "\U0001f50e Reviewer — " in html
|
||||
assert "Verdict: APPROVE" in html
|
||||
assert "Длительность: 2m 00s" in html
|
||||
assert "12-review.md" in html
|
||||
|
||||
|
||||
def test_tc04_reviewer_verdict_request_changes(tmp_path):
|
||||
wt = _wt_with_files(tmp_path, {
|
||||
"12-review.md": "---\nverdict: REQUEST_CHANGES\n---\nblockers...",
|
||||
})
|
||||
html = U.build_status_comment(
|
||||
"reviewer",
|
||||
repo=REPO, branch=BRANCH, work_item_id=WID,
|
||||
duration_s=45, worktree_root=wt,
|
||||
)
|
||||
assert "Verdict: REQUEST_CHANGES" in html
|
||||
assert "Длительность: 45s" in html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-05: reviewer with NO 12-review.md -> graceful (no Verdict, no Review link)
|
||||
# but Длительность and header still present.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc05_reviewer_missing_artifact_graceful(tmp_path):
|
||||
wt = _wt_with_files(tmp_path, {}) # empty docs dir
|
||||
html = U.build_status_comment(
|
||||
"reviewer",
|
||||
repo=REPO, branch=BRANCH, work_item_id=WID,
|
||||
duration_s=30, worktree_root=wt,
|
||||
)
|
||||
assert "\U0001f50e Reviewer — " in html
|
||||
assert "Verdict" not in html
|
||||
# Link to 12-review.md is dropped (AC-8 graceful).
|
||||
assert "12-review.md" not in html
|
||||
# Duration still printed when known.
|
||||
assert "Длительность: 30s" in html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-06 / TC-07: tester verdict via frontmatter (verdict OR status)
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc06_tester_pass(tmp_path):
|
||||
wt = _wt_with_files(tmp_path, {
|
||||
"13-test-report.md": "---\nverdict: PASS\n---\n",
|
||||
})
|
||||
html = U.build_status_comment(
|
||||
"tester",
|
||||
repo=REPO, branch=BRANCH, work_item_id=WID,
|
||||
duration_s=240, worktree_root=wt,
|
||||
)
|
||||
assert "\U0001f9ea Tester — " in html
|
||||
assert "Verdict: PASS" in html
|
||||
assert "Длительность: 4m 00s" in html
|
||||
assert "13-test-report.md" in html
|
||||
|
||||
|
||||
def test_tc07_tester_fail(tmp_path):
|
||||
wt = _wt_with_files(tmp_path, {
|
||||
"13-test-report.md": "---\nverdict: FAIL\n---\n",
|
||||
})
|
||||
html = U.build_status_comment(
|
||||
"tester",
|
||||
repo=REPO, branch=BRANCH, work_item_id=WID,
|
||||
duration_s=240, worktree_root=wt,
|
||||
)
|
||||
assert "Verdict: FAIL" in html
|
||||
assert "Длительность: 4m 00s" in html
|
||||
|
||||
|
||||
def test_tc07b_tester_falls_back_to_status_key(tmp_path):
|
||||
# Some testers used `status:` instead of `verdict:` (ET-006 / ET-008 pattern).
|
||||
wt = _wt_with_files(tmp_path, {
|
||||
"13-test-report.md": "---\nstatus: PASSED\n---\n",
|
||||
})
|
||||
html = U.build_status_comment(
|
||||
"tester",
|
||||
repo=REPO, branch=BRANCH, work_item_id=WID,
|
||||
duration_s=10, worktree_root=wt,
|
||||
)
|
||||
assert "Verdict: PASSED" in html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-08 / TC-09: deployer status via frontmatter
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc08_deployer_deploy_status_success(tmp_path):
|
||||
wt = _wt_with_files(tmp_path, {
|
||||
"14-deploy-log.md": "---\ndeploy_status: SUCCESS\n---\n",
|
||||
})
|
||||
html = U.build_status_comment(
|
||||
"deployer",
|
||||
repo=REPO, branch=BRANCH, work_item_id=WID,
|
||||
stage="deploy", duration_s=120, worktree_root=wt,
|
||||
)
|
||||
assert "\U0001f680 Deployer — " in html
|
||||
assert "Status: SUCCESS" in html
|
||||
assert "Длительность: 2m 00s" in html
|
||||
assert "14-deploy-log.md" in html
|
||||
|
||||
|
||||
def test_tc09_deployer_staging_status_success(tmp_path):
|
||||
wt = _wt_with_files(tmp_path, {
|
||||
"15-staging-log.md": "---\nstaging_status: SUCCESS\n---\n",
|
||||
})
|
||||
html = U.build_status_comment(
|
||||
"deployer",
|
||||
repo=REPO, branch=BRANCH, work_item_id=WID,
|
||||
stage="deploy-staging", duration_s=60, worktree_root=wt,
|
||||
)
|
||||
assert "Status: SUCCESS" in html
|
||||
assert "Длительность: 1m 00s" in html
|
||||
# The staging-stage helper links 15-staging-log.md, not 14-deploy-log.md.
|
||||
assert "15-staging-log.md" in html
|
||||
assert "14-deploy-log.md" not in html
|
||||
|
||||
|
||||
def test_deployer_status_failed_drives_status_line(tmp_path):
|
||||
wt = _wt_with_files(tmp_path, {
|
||||
"14-deploy-log.md": "---\ndeploy_status: FAILED\n---\n",
|
||||
})
|
||||
html = U.build_status_comment(
|
||||
"deployer",
|
||||
repo=REPO, branch=BRANCH, work_item_id=WID,
|
||||
stage="deploy", duration_s=5, worktree_root=wt,
|
||||
)
|
||||
assert "Status: FAILED" in html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-10: gitea_public_url is preferred; falls back to gitea_url when empty.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc10_url_fallback_to_gitea_url(monkeypatch):
|
||||
from src.config import settings
|
||||
monkeypatch.setattr(settings, "gitea_public_url", "", raising=False)
|
||||
monkeypatch.setattr(settings, "gitea_url", "http://localhost:3000", raising=False)
|
||||
html = U.build_status_comment(
|
||||
"developer",
|
||||
repo=REPO, branch=BRANCH, work_item_id=WID,
|
||||
pr_number=7, duration_s=15,
|
||||
)
|
||||
assert "http://localhost:3000/admin/enduro-trails/pulls/7" in html
|
||||
# And the public URL is not there because it was empty.
|
||||
assert "git.mva154.duckdns.org" not in html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-12: frontmatter parser is graceful — missing file / empty / bad YAML -> None
|
||||
# (the comment still publishes the header + duration, just no Verdict / Status).
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc12_frontmatter_missing_file_no_crash(tmp_path):
|
||||
from src.frontmatter import read_frontmatter_value
|
||||
assert read_frontmatter_value(str(tmp_path / "nope.md"), "verdict") is None
|
||||
|
||||
|
||||
def test_tc12_frontmatter_empty_no_crash(tmp_path):
|
||||
p = tmp_path / "empty.md"
|
||||
p.write_text("")
|
||||
from src.frontmatter import read_frontmatter_value
|
||||
assert read_frontmatter_value(str(p), "verdict") is None
|
||||
|
||||
|
||||
def test_tc12_frontmatter_bad_yaml_no_crash(tmp_path):
|
||||
p = tmp_path / "bad.md"
|
||||
p.write_text("---\nverdict: [unterminated\n---\nbody")
|
||||
from src.frontmatter import read_frontmatter_value
|
||||
assert read_frontmatter_value(str(p), "verdict") is None
|
||||
|
||||
|
||||
def test_tc12_frontmatter_missing_key_returns_none(tmp_path):
|
||||
p = tmp_path / "ok.md"
|
||||
p.write_text("---\nother: value\n---\nbody")
|
||||
from src.frontmatter import read_frontmatter_value
|
||||
assert read_frontmatter_value(str(p), "verdict") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-23: duration_s=None and no task_id -> the Длительность line is OMITTED.
|
||||
# Header / description / artifact links remain.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc23_no_duration_no_line(tmp_path):
|
||||
wt = _wt_with_files(tmp_path, {"06-adr/ADR-001-x.md": "x"})
|
||||
html_none = U.build_status_comment(
|
||||
"architect",
|
||||
repo=REPO, branch=BRANCH, work_item_id=WID,
|
||||
duration_s=None, worktree_root=wt,
|
||||
)
|
||||
html_default = U.build_status_comment(
|
||||
"architect",
|
||||
repo=REPO, branch=BRANCH, work_item_id=WID,
|
||||
worktree_root=wt,
|
||||
)
|
||||
for html in (html_none, html_default):
|
||||
assert "Длительность" not in html
|
||||
# But the header, description and ADR link are still there.
|
||||
assert "\U0001f4d0 Architect — " in html
|
||||
assert "архитектурную" in html
|
||||
assert "06-adr" in html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Extra: usage tail is rendered as <sub> when non-zero, suppressed otherwise.
|
||||
# (Backs up ADR-001 §3 and keeps the old usage_comment test contract.)
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_usage_tail_rendered_when_non_zero():
|
||||
html = U.build_status_comment(
|
||||
"developer",
|
||||
repo=REPO, branch=BRANCH, work_item_id=WID,
|
||||
usage={"input_tokens": 45231, "output_tokens": 12100, "cost_usd": 0.21},
|
||||
)
|
||||
assert "<sub>" in html and "</sub>" in html
|
||||
assert "45.2k in" in html
|
||||
assert "12.1k out" in html
|
||||
assert "$0.21" in html
|
||||
|
||||
|
||||
def test_usage_tail_suppressed_when_all_zero():
|
||||
html = U.build_status_comment("developer", repo=REPO, branch=BRANCH)
|
||||
assert "<sub>" not in html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-1 / AC-5 literal strings — fixed wording per role.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_ac1_architect_header_literal():
|
||||
html = U.build_status_comment("architect", repo=REPO, branch=BRANCH,
|
||||
work_item_id=WID, duration_s=10)
|
||||
assert "\U0001f4d0 Architect — " in html
|
||||
|
||||
|
||||
def test_ac5_deployer_deploy_description():
|
||||
html = U.build_status_comment(
|
||||
"deployer", repo=REPO, branch=BRANCH, work_item_id=WID, stage="deploy",
|
||||
)
|
||||
assert "прод-деплой" in html
|
||||
|
||||
|
||||
def test_ac5_deployer_staging_description():
|
||||
html = U.build_status_comment(
|
||||
"deployer", repo=REPO, branch=BRANCH, work_item_id=WID, stage="deploy-staging",
|
||||
)
|
||||
assert "staging-деплой" in html
|
||||
Reference in New Issue
Block a user