Compare commits

..

7 Commits

Author SHA1 Message Date
Dev Agent
00325bcab0 fix(plane): resolve issue states per-project instead of hardcoded enduro UUIDs (ORCH-10)
All checks were successful
CI / test (push) Successful in 12s
CI / test (pull_request) Successful in 10s
ORCH-10 root cause: PLANE_STATES was a global dict hardcoding enduro-trails
UUIDs. The webhook comparison  only
matched ET UUID (b873d9eb) and silently ignored the ORCH in_progress UUID
(e331bfb3), blocking pipeline start for all orchestrator-project tasks.

Changes:
- src/plane_sync.py:
  * Rename PLANE_STATES -> _DEFAULT_STATES (enduro UUIDs kept as safe fallback).
  * PLANE_STATES preserved as alias to _DEFAULT_STATES (backward compat).
  * Add get_project_states(project_id) -> {logical_key: state_uuid}:
    fetches Plane API GET /projects/<id>/states/, maps by state name,
    caches per project_id, falls back to _DEFAULT_STATES on API failure.
  * Add _STATES_CACHE: dict, reload_project_states(project_id=None).
  * Add _PLANE_NAME_TO_KEY mapping and _STAGE_TO_STATE_KEY for clean lookup.
  * Add stage_to_state(stage, project_id) using get_project_states().
  * update_issue_state() uses stage_to_state() instead of STAGE_TO_STATE dict.
  * set_issue_{needs_input,in_review,blocked,done,in_progress,stage_state}()
    all resolve state UUID via get_project_states(project_id) instead of
    the global PLANE_STATES dict.

- src/webhooks/plane.py:
  * handle_issue_updated: import get_project_states, resolve proj_states per
    incoming project_id, compare new_state against proj_states["in_progress"],
    proj_states["approved"], proj_states["rejected"].
  * start_pipeline QG-0 blocked path: use get_project_states(plane_project_id)
    instead of PLANE_STATES["blocked"].

- tests/test_orch10_states.py: 23 new tests covering:
  * get_project_states returns correct UUIDs for both ET and ORCH projects.
  * API failure / empty response / None project_id -> _DEFAULT_STATES fallback.
  * Caching and reload_project_states (per-project and full flush).
  * stage_to_state() per-project resolution.
  * Webhook in_progress triggers pipeline for BOTH b873d9eb (ET) and e331bfb3 (ORCH).
  * Webhook approved/rejected routes correctly per project.
  * PLANE_STATES alias and _DEFAULT_STATES backward compat.
2026-06-05 14:23:31 +03:00
5ecd1c4692 Merge pull request 'docs(orchestrator): doc canon + CLAUDE.md + agent prompts + reviewer-gate (self-hosting)' (#32) from docs/ORCH-9-canon into main 2026-06-05 13:28:50 +03:00
Dev Agent
7c68d1d812 docs(orchestrator): adopt enduro doc canon + CLAUDE.md + ADR (ORCH-9)
All checks were successful
CI / test (pull_request) Successful in 9s
2026-06-05 12:33:55 +03:00
f1b31463ad Merge pull request 'feat(pipeline): add deploy-staging gate before prod deploy (ORCH-35)' (#31) from feature/ORCH-35-staging-gate into main 2026-06-05 10:43:38 +03:00
Dev Agent
e0c14fae5f fix(pipeline): make deploy-staging gate conditional on self-hosting repo (ORCH-35)
All checks were successful
CI / test (push) Successful in 10s
CI / test (pull_request) Successful in 10s
2026-06-05 10:36:46 +03:00
Dev Agent
e0b6e92b09 feat(pipeline): add deploy-staging gate before prod deploy (ORCH-35)
All checks were successful
CI / test (push) Successful in 9s
CI / test (pull_request) Successful in 9s
2026-06-05 10:06:06 +03:00
e405a55f9d Merge pull request 'feat(staging): add orchestrator deploy hook with health-check and auto-rollback (ORCH-34)' (#30) from feature/ORCH-34-deploy-hook into main 2026-06-05 09:46:18 +03:00
36 changed files with 1976 additions and 68 deletions

View 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

View 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`)

View File

@@ -0,0 +1,80 @@
---
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)
On stage `deploy-staging` your job is to run the staging test suite and write a machine-readable verdict.
### Steps:
1. Run the staging test suite against the live staging environment:
```bash
python3 scripts/staging_check.py --base-url http://localhost:8501 --mode stub
```
2. Check the exit code:
- Exit code **0** = all tests PASS → `staging_status: SUCCESS`
- Exit code **non-zero** = tests FAILED → `staging_status: FAILED`
3. Write the verdict to `docs/work-items/<work_item_id>/15-staging-log.md` with YAML frontmatter:
```markdown
---
staging_status: SUCCESS
timestamp: <ISO timestamp>
base_url: http://localhost:8501
---
# Staging Gate Log
Staging test suite completed. All checks passed.
```
Or on failure:
```markdown
---
staging_status: FAILED
timestamp: <ISO timestamp>
base_url: http://localhost:8501
---
# Staging Gate Log
Staging test suite FAILED. See details below.
<paste test output here>
```
4. Merge `15-staging-log.md` into `main` (commit + push, same as deploy log pattern).
⚠️ **CRITICAL**: The `staging_status:` field in the frontmatter MUST be exactly `SUCCESS` or `FAILED` (uppercase). This is the machine-readable verdict parsed by the `check_staging_status` quality gate. No other values are accepted.
---
## Stage: `deploy` (Production Deploy — ORCH-36, future)
On stage `deploy` your job is to perform (or simulate) the production deployment and write a machine-readable verdict to `docs/work-items/<work_item_id>/14-deploy-log.md` with frontmatter field `deploy_status: SUCCESS|FAILED`.
This stage is only reached if the staging gate (`deploy-staging`) passed with `staging_status: SUCCESS`.
⚠️ **CRITICAL**: Do NOT trigger real production deploys unless explicitly instructed. Real docker/SSH deploys are handled by `scripts/orchestrator-deploy-hook.sh` (ORCH-36).
---
## General Rules
- Always write machine-readable YAML frontmatter — the quality gates parse ONLY the frontmatter fields, never the body prose.
- Never push directly to `main`. Always use a PR or the artifact merge pattern.
- Never modify `.env`, `.env.staging`, `docker-compose.yml`, or production infrastructure.

View 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`
- Перезапускать прод-контейнер орка

View 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 без ссылки на правило
- Пропускать проверку документации

View 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-контейнере деструктивные операции

24
CHANGELOG.md Normal file
View File

@@ -0,0 +1,24 @@
# Changelog
Формат: [Keep a Changelog](https://keepachangelog.com/). Записи — на смысловой PR/задачу.
## [Unreleased]
### Added
- **Документация по канону** (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
- Цепочка стадий: `... 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
View 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).*

View File

@@ -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`.
## Ключевые механизмы

View File

@@ -0,0 +1,77 @@
# Архитектура 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`.
## База данных (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.*

View 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` → Конвенции).

View 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 из реестра).

View 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).

View 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 (исполняемый самодеплой).

View File

@@ -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

96
docs/operations/INFRA.md Normal file
View File

@@ -0,0 +1,96 @@
# 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` |
| `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) — изоляция.
## ⚠️ 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.*

View File

@@ -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):

View File

@@ -440,6 +440,130 @@ def check_deploy_status(repo: str, work_item_id: str, branch: str | None = None)
return False, "Deploy log not found (14-deploy-log.md)"
# ---------------------------------------------------------------------------
# Self-hosting detection: staging-infra (localhost:8501) exists ONLY for the
# orchestrator repo itself (self-hosting). Other repos have no staging instance
# and their deployer prompts know nothing about it -- the gate must be a no-op
# for them. The repo value is the plain gitea repo name (ProjectConfig.repo),
# matching what _run_qg/advance_stage pass in. See ORCH-35 / PR #31.
# ---------------------------------------------------------------------------
SELF_HOSTING_REPO = "orchestrator"
def is_self_hosting_repo(repo: str) -> bool:
"""Return True iff repo is the self-hosted orchestrator (has staging infra).
Comparison is case-insensitive and strips whitespace for safety, but in
practice repo comes from the gitea webhook payload .repository.name which
is always lowercase (confirmed via projects.py registry entry).
"""
return (repo or "").strip().lower() == SELF_HOSTING_REPO.lower()
def _parse_staging_status(content: str) -> tuple[bool, str]:
"""Parse a 15-staging-log.md body and map its `staging_status:` frontmatter to a
quality-gate verdict. Reads ONLY the machine-readable YAML field, never prose.
staging_status: SUCCESS -> (True, "Staging status: SUCCESS")
staging_status: FAILED -> (False, "Staging status: FAILED")
missing field / no frontmatter / bad YAML -> (False, <reason>)
"""
import yaml
status = None
if content.startswith("---"):
parts = content.split("---", 2)
if len(parts) >= 3:
try:
fm = yaml.safe_load(parts[1]) or {}
except yaml.YAMLError as e:
return False, f"Invalid YAML frontmatter in staging log: {e}"
status = str(fm.get("staging_status", "")).upper().strip()
if status == "SUCCESS":
return True, "Staging status: SUCCESS"
if status == "FAILED":
return False, "Staging status: FAILED"
return False, f"No machine-readable staging_status in frontmatter (got: {status!r})"
def _staging_log_from_main(repo: str, work_item_id: str) -> str | None:
"""Best-effort read of 15-staging-log.md from origin/main on the shared clone.
The deployer writes 15-staging-log.md and merges the staging artifacts into main
via a separate PR (mirroring the deploy-log pattern), so the file lands in
origin/main, NOT in the feature branch worktree the gate normally reads.
This recovers it from main.
Degrades gracefully: any git failure (no clone, network/fetch error, file
absent in main) returns None instead of raising, so the caller falls back to
the plain "not found" verdict. Never raises.
"""
repo_clone = os.path.join(settings.repos_dir, repo)
if not os.path.isdir(os.path.join(repo_clone, ".git")):
return None
rel = f"docs/work-items/{work_item_id}/15-staging-log.md"
try:
# Refresh origin/main so we see freshly-merged staging artifacts.
subprocess.run(
["git", "-C", repo_clone, "fetch", "origin", "main"],
check=False, capture_output=True, timeout=30,
)
show = subprocess.run(
["git", "-C", repo_clone, "show", f"origin/main:{rel}"],
check=False, capture_output=True, text=True, timeout=15,
)
except (subprocess.SubprocessError, OSError) as e:
logger.warning("staging-log origin/main lookup failed for %s/%s: %s", repo, work_item_id, e)
return None
if show.returncode != 0:
return None
return show.stdout
def check_staging_status(repo: str, work_item_id: str, branch: str | None = None) -> tuple[bool, str]:
"""
Gate the deploy-staging -> deploy transition on the deployer's machine-readable
verdict in 15-staging-log.md frontmatter (staging_status: SUCCESS|FAILED).
ORCH-35 conditional gate (Variant A):
- Non-self-hosting repos (anything other than "orchestrator") have no staging
instance and no deployer knowledge of it -> gate is an immediate pass.
- Self-hosting repo ("orchestrator") -> real check: reads ONLY the machine-
readable staging_status: field from YAML frontmatter, never body prose.
Mirrors check_deploy_status (БАГ 8) for the self-hosting path.
Lookup order (self-hosting only): worktree -> origin/main -> not found.
Returns:
(True, "Staging gate N/A for <repo>") -> non-self-hosting repo (instant pass)
(True, ...) -> staging_status: SUCCESS (self-hosting path)
(False, ...) -> staging_status: FAILED, missing field, or no frontmatter
"""
# Variant A: non-self-hosting repos have no staging infra -- skip entirely.
if not is_self_hosting_repo(repo):
return True, f"Staging gate N/A for {repo}"
# Self-hosting (orchestrator) path: real verdict check.
repo_path = _repo_path(repo, branch)
log_path = os.path.join(repo_path, f"docs/work-items/{work_item_id}/15-staging-log.md")
if os.path.isfile(log_path):
try:
with open(log_path, "r") as f:
content = f.read()
except OSError as e:
return False, f"Error reading staging log: {e}"
return _parse_staging_status(content)
# Not in the feature worktree -- the deployer may have merged it into main.
main_content = _staging_log_from_main(repo, work_item_id)
if main_content is not None:
return _parse_staging_status(main_content)
return False, "Staging log not found (15-staging-log.md)"
# Registry for dynamic lookup by name
QG_CHECKS = {
"check_analysis_approved": check_analysis_approved,
@@ -451,4 +575,5 @@ QG_CHECKS = {
"check_reviewer_verdict": check_reviewer_verdict,
"check_tests_local": check_tests_local,
"check_deploy_status": check_deploy_status,
"check_staging_status": check_staging_status,
}

View File

@@ -517,6 +517,32 @@ def _handle_qg_failure_rollbacks(
f"(job_id={new_job})"
)
# ORCH-35: deployer staging verdict FAILED -> roll deploy-staging back to development.
# Staging-провал = код плох; откат на development по образцу БАГ-8 (deploy->development).
# НЕ трогает ветку check_deploy_status ниже.
if agent == "deployer" and qg_name == "check_staging_status":
update_task_stage(task_id, "development")
notify_stage_change(task_id, current_stage, "development")
plane_notify_stage(work_item_id, current_stage, "development")
result.rolled_back_to = "development"
set_issue_blocked(work_item_id)
notify_qg_failure(task_id, "deploy-staging", "check_staging_status", reason)
plane_add_comment(
work_item_id,
f"\u274c Staging gate FAILED ({reason}). Rolled back to development. "
f"Developer \u043d\u0443\u0436\u0435\u043d \u0434\u043b\u044f \u0444\u0438\u043a\u0441\u0430.",
author="deployer",
)
send_telegram(
f"\U0001f6a8 {work_item_id}: Staging FAILED ({reason}). "
f"Rolled back to development. Needs fix."
)
result.alerted = True
logger.error(
f"Task {task_id}: deployer staging verdict FAILED, rolled back deploy-staging -> "
f"development ({reason})"
)
# БАГ 8: deployer verdict FAILED -> roll deploy back to development.
# The launcher's exit_code-based guard (launcher.py:475) never fires because
# the LLM process exit code is always 0; this gate fires on the machine-readable

View File

@@ -1,7 +1,7 @@
"""Stage machine for orchestrator pipeline.
Stages:
created → analysis → architecture → development → review → testing → deploy → done
created → analysis → architecture → development → review → testing → deploy-staging → deploy → done
Each stage defines:
- next: the stage to advance to
@@ -15,8 +15,9 @@ STAGE_TRANSITIONS = {
"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", "agent": "deployer", "qg": "check_tests_passed"},
"deploy": {"next": "done", "agent": None, "qg": "check_deploy_status"},
"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},
}

View File

@@ -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}")

462
tests/test_orch10_states.py Normal file
View 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)}"
)

View File

@@ -19,6 +19,7 @@ from src.qg.checks import (
check_tests_passed,
check_tests_local,
check_deploy_status,
check_staging_status,
)
from src.stages import get_qg_for_stage
@@ -448,3 +449,185 @@ class TestCheckTestsLocal:
assert "../../tests/" in cmd
assert kwargs["cwd"] == os.path.join(str(tmp_path), "src", "api")
class TestCheckStagingStatus:
"""ORCH-35 conditional gate (Variant A): deploy-staging gate is active ONLY for
the self-hosting orchestrator repo (has staging infra on localhost:8501). All
other repos pass immediately with "Staging gate N/A for <repo>".
Self-hosting path: reads machine-readable staging_status: from 15-staging-log.md
frontmatter. Mirrors check_deploy_status pattern.
"""
@pytest.fixture()
def orch_dir(self, tmp_path, monkeypatch):
"""Temp orchestrator repo dir (self-hosting)."""
monkeypatch.setattr("src.qg.checks.settings.repos_dir", str(tmp_path))
d = tmp_path / "orchestrator"
d.mkdir(exist_ok=True)
return d
def _write_log(self, repo_dir, content, wi="ORCH-035"):
wi_dir = repo_dir / "docs" / "work-items" / wi
wi_dir.mkdir(parents=True, exist_ok=True)
(wi_dir / "15-staging-log.md").write_text(content)
# ------------------------------------------------------------------
# Self-hosting (orchestrator) path -- real file check
# ------------------------------------------------------------------
def test_success_verdict_passes(self, orch_dir):
self._write_log(
orch_dir,
"---\nstaging_status: SUCCESS\ntimestamp: 2026-06-05T00:00:00Z\n---\n\nAll staging tests passed.\n",
)
from src.qg.checks import check_staging_status
passed, reason = check_staging_status("orchestrator", "ORCH-035")
assert passed is True
assert "SUCCESS" in reason
def test_failed_verdict_fails(self, orch_dir):
self._write_log(
orch_dir,
"---\nstaging_status: FAILED\ntimestamp: 2026-06-05T00:00:00Z\n---\n\n2 tests failed.\n",
)
from src.qg.checks import check_staging_status
passed, reason = check_staging_status("orchestrator", "ORCH-035")
assert passed is False
assert "FAILED" in reason
def test_no_file_fails_for_self_hosting(self, orch_dir):
from src.qg.checks import check_staging_status
passed, reason = check_staging_status("orchestrator", "ORCH-035")
assert passed is False
assert "not found" in reason.lower()
def test_no_field_fails(self, orch_dir):
# Frontmatter present but no staging_status field -> must NOT pass.
self._write_log(
orch_dir,
"---\nversion: v0.0.3\n---\n\nStatus: all good (prose only).\n",
)
from src.qg.checks import check_staging_status
passed, reason = check_staging_status("orchestrator", "ORCH-035")
assert passed is False
def test_prose_only_no_frontmatter_fails(self, orch_dir):
# Prose mentioning SUCCESS but no machine-readable frontmatter -> fail.
self._write_log(
orch_dir,
"# Staging Log\n\nStatus: SUCCESS (prose, not frontmatter).\n",
)
from src.qg.checks import check_staging_status
passed, reason = check_staging_status("orchestrator", "ORCH-035")
assert passed is False
def test_origin_main_success_passes_when_absent_in_worktree(self, monkeypatch):
# Deployer merged 15-staging-log.md into main; not in worktree -> recover from main.
monkeypatch.setattr(
"src.qg.checks._staging_log_from_main",
lambda repo, wi: "---\nstaging_status: SUCCESS\n---\n\nAll good.\n",
)
from src.qg.checks import check_staging_status
passed, reason = check_staging_status("orchestrator", "ORCH-035-main")
assert passed is True
assert "SUCCESS" in reason
def test_origin_main_failed_fails(self, monkeypatch):
monkeypatch.setattr(
"src.qg.checks._staging_log_from_main",
lambda repo, wi: "---\nstaging_status: FAILED\n---\n\nboom.\n",
)
from src.qg.checks import check_staging_status
passed, reason = check_staging_status("orchestrator", "ORCH-035-main")
assert passed is False
assert "FAILED" in reason
def test_absent_everywhere_fails(self, monkeypatch):
monkeypatch.setattr(
"src.qg.checks._staging_log_from_main", lambda repo, wi: None
)
from src.qg.checks import check_staging_status
passed, reason = check_staging_status("orchestrator", "ORCH-035-absent")
assert passed is False
assert "not found" in reason.lower()
# ------------------------------------------------------------------
# Non-self-hosting path -- instant pass, no file dependency
# ------------------------------------------------------------------
def test_non_self_hosting_passes_immediately_no_file(self, tmp_path, monkeypatch):
"""Non-self-hosting repo: gate is N/A even without a staging log file."""
monkeypatch.setattr("src.qg.checks.settings.repos_dir", str(tmp_path))
from src.qg.checks import check_staging_status
passed, reason = check_staging_status("enduro-trails", "ET-035")
assert passed is True
assert "N/A" in reason
assert "enduro-trails" in reason
def test_non_self_hosting_passes_regardless_of_file_content(self, tmp_path, monkeypatch):
"""Even a FAILED staging log must not block a non-self-hosting repo."""
monkeypatch.setattr("src.qg.checks.settings.repos_dir", str(tmp_path))
et_dir = tmp_path / "enduro-trails" / "docs" / "work-items" / "ET-035"
et_dir.mkdir(parents=True)
(et_dir / "15-staging-log.md").write_text(
"---\nstaging_status: FAILED\n---\nShould be ignored.\n"
)
from src.qg.checks import check_staging_status
passed, reason = check_staging_status("enduro-trails", "ET-035")
assert passed is True
assert "N/A" in reason
def test_unknown_repo_also_passes_immediately(self, tmp_path, monkeypatch):
"""Any repo that is not orchestrator gets N/A gate."""
monkeypatch.setattr("src.qg.checks.settings.repos_dir", str(tmp_path))
from src.qg.checks import check_staging_status
passed, reason = check_staging_status("some-other-project", "XY-001")
assert passed is True
assert "N/A" in reason
# ------------------------------------------------------------------
# is_self_hosting_repo helper
# ------------------------------------------------------------------
def test_is_self_hosting_true_for_orchestrator(self):
from src.qg.checks import is_self_hosting_repo
assert is_self_hosting_repo("orchestrator") is True
def test_is_self_hosting_case_insensitive(self):
from src.qg.checks import is_self_hosting_repo
assert is_self_hosting_repo("Orchestrator") is True
assert is_self_hosting_repo("ORCHESTRATOR") is True
def test_is_self_hosting_false_for_enduro_trails(self):
from src.qg.checks import is_self_hosting_repo
assert is_self_hosting_repo("enduro-trails") is False
def test_is_self_hosting_false_for_empty(self):
from src.qg.checks import is_self_hosting_repo
assert is_self_hosting_repo("") is False
assert is_self_hosting_repo(None) is False
# ------------------------------------------------------------------
# Stage machinery (regression: must not be broken)
# ------------------------------------------------------------------
def test_deploy_staging_qg_is_check_staging_status(self):
assert get_qg_for_stage("deploy-staging") == "check_staging_status"
def test_registered_in_qg_checks(self):
from src.qg.checks import QG_CHECKS, check_staging_status
assert QG_CHECKS.get("check_staging_status") is check_staging_status
def test_deploy_stage_qg_still_check_deploy_status(self):
"""Regression: existing deploy QG must not be broken."""
assert get_qg_for_stage("deploy") == "check_deploy_status"
def test_stage_chain(self):
"""Full chain: testing->deploy-staging->deploy->done."""
from src.stages import get_next_stage
assert get_next_stage("testing") == "deploy-staging"
assert get_next_stage("deploy-staging") == "deploy"
assert get_next_stage("deploy") == "done"

View File

@@ -136,7 +136,7 @@ class TestHappyPathAgentSelection:
("architecture", "development", "developer"),
("development", "review", "reviewer"),
("review", "testing", "tester"),
("testing", "deploy", "deployer"),
("testing", "deploy-staging", "deployer"),
],
)
def test_advance_launches_current_stage_agent(
@@ -507,6 +507,120 @@ class TestAnalysisApprovedFlow:
flow.assert_called_once()
# ---------------------------------------------------------------------------
# ORCH-35: deploy-staging gate — rollback on staging failure
# ---------------------------------------------------------------------------
class TestStagingGate:
"""deploy-staging -> deploy must be gated on check_staging_status.
FAILED verdict rolls back to development (same as deploy БАГ-8 pattern:
staging failure = code is bad, needs developer fix)."""
def test_staging_success_advances_to_deploy(self, monkeypatch):
"""Happy path: staging SUCCESS -> advance to deploy (no agent launched)."""
monkeypatch.setattr(
stage_engine, "QG_CHECKS",
{**stage_engine.QG_CHECKS, "check_staging_status": _pass},
)
task_id = _make_task("deploy-staging")
res = advance_stage(
task_id, "deploy-staging", "enduro-trails", "ET-035",
"feature/ET-035-x", finished_agent="deployer",
)
assert res.advanced is True
assert res.to_stage == "deploy"
assert _stage(task_id) == "deploy"
# deploy-staging has agent=deployer, so deployer is enqueued for deploy stage
assert res.enqueued_agent == "deployer"
jobs = _jobs()
assert len(jobs) == 1
assert jobs[0]["agent"] == "deployer"
def test_staging_failed_rolls_back_to_development(self, monkeypatch):
"""ORCH-35: staging FAILED -> roll back to development, not to testing."""
monkeypatch.setattr(
stage_engine, "QG_CHECKS",
{**stage_engine.QG_CHECKS,
"check_staging_status": _fail("Staging status: FAILED")},
)
task_id = _make_task("deploy-staging")
res = advance_stage(
task_id, "deploy-staging", "enduro-trails", "ET-035",
"feature/ET-035-x", finished_agent="deployer",
)
assert res.advanced is False
assert res.rolled_back_to == "development"
assert _stage(task_id) == "development" # NOT deploy, NOT testing
assert res.alerted is True
assert stage_engine.set_issue_blocked.called
assert stage_engine.send_telegram.called
def test_staging_failed_does_not_reach_deploy(self, monkeypatch):
"""Prod deploy is unreachable if staging gate is not green."""
monkeypatch.setattr(
stage_engine, "QG_CHECKS",
{**stage_engine.QG_CHECKS,
"check_staging_status": _fail("Staging log not found")},
)
task_id = _make_task("deploy-staging")
res = advance_stage(
task_id, "deploy-staging", "enduro-trails", "ET-035",
"feature/ET-035-x", finished_agent="deployer",
)
assert res.advanced is False
# Task must NOT be in deploy stage
assert _stage(task_id) != "deploy"
def test_staging_missing_log_rolls_back(self, monkeypatch):
"""Missing 15-staging-log.md -> gate fails -> rollback to development."""
monkeypatch.setattr(
stage_engine, "QG_CHECKS",
{**stage_engine.QG_CHECKS,
"check_staging_status": _fail("Staging log not found (15-staging-log.md)")},
)
task_id = _make_task("deploy-staging")
res = advance_stage(
task_id, "deploy-staging", "enduro-trails", "ET-035",
"feature/ET-035-x", finished_agent="deployer",
)
assert res.advanced is False
assert _stage(task_id) == "development"
def test_testing_to_deploy_staging_advance(self, monkeypatch):
"""testing -> deploy-staging: deployer is enqueued (ORCH-35 chain check)."""
monkeypatch.setattr(
stage_engine, "QG_CHECKS",
{**stage_engine.QG_CHECKS, "check_tests_passed": _pass},
)
task_id = _make_task("testing")
res = advance_stage(
task_id, "testing", "enduro-trails", "ET-035",
"feature/ET-035-x", finished_agent="tester",
)
assert res.advanced is True
assert res.to_stage == "deploy-staging"
assert _stage(task_id) == "deploy-staging"
assert res.enqueued_agent == "deployer"
def test_deploy_still_rolls_back_on_check_deploy_status_fail(self, monkeypatch):
"""Existing БАГ-8 rollback must still work for deploy stage (regression guard)."""
monkeypatch.setattr(
stage_engine, "QG_CHECKS",
{**stage_engine.QG_CHECKS,
"check_deploy_status": _fail("Deploy status: FAILED")},
)
task_id = _make_task("deploy")
res = advance_stage(
task_id, "deploy", "enduro-trails", "ET-011",
"feature/ET-011-x", finished_agent="deployer",
)
assert res.advanced is False
assert res.rolled_back_to == "development"
assert _stage(task_id) == "development"
assert res.alerted is True
# ---------------------------------------------------------------------------
# launcher + plane both delegate to the engine
# ---------------------------------------------------------------------------