Files
orchestrator/docs/architecture/internals.md
dev 8a292b9d33
All checks were successful
CI / test (pull_request) Successful in 12s
feat(agents): configurable LLM model + effort per-agent and per-project (ORCH-41)
Vынести модель/effort агентов из хардкода launcher.py в конфиг.
- config.py: ORCH_AGENT_MODEL_<AGENT>/_DEFAULT (default claude-opus-4-8),
  ORCH_AGENT_EFFORT_<AGENT>/_DEFAULT (думающие=high, tester/deployer=medium),
  ORCH_AGENT_FALLBACK_MODEL.
- projects.py: ProjectConfig.agent_models/agent_efforts (field(default_factory=dict)),
  парсинг из projects_json через _coerce_str_map.
- launcher.py: resolve_agent_model/resolve_agent_effort (project>env>default>пусто),
  валидация effort {low,medium,high,xhigh,max}; убран хардкод model:opus;
  собираются флаги --model/--effort/--fallback-model.
- tests: test_resolve_agent_model.py, test_resolve_agent_effort.py.
- docs: INFRA.md, internals.md, CHANGELOG.md.
2026-06-05 16:16:57 +00:00

343 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Архитектура Orchestrator
## Обзор
Orchestrator — event-driven FastAPI сервис, который управляет жизненным циклом задач разработки через мульти-агентный пайплайн. Каждая задача проходит через фиксированные стадии, на каждой из которых работает специализированный Claude CLI агент.
## Компоненты
### 1. Webhook Receivers
#### Plane Webhook (`src/webhooks/plane.py`)
- **Фильтр по проекту (ORCH-6):** извлекает `data.project` (Plane project uuid) и игнорирует событие, если проект не в реестре (`known_plane_project_ids()`) → ответ `{"status":"ignored","reason":"unknown project"}`. Это предотвращает инцидент 2026-06-02 (webhook на весь workspace без фильтра).
- Принимает `work_item.created` — резолвит repo/prefix/Plane-проект из реестра по `project`, создаёт задачу в DB, запускает analyst
- Принимает `work_item.updated` — синхронизация статусов
#### Реестр проектов (`src/projects.py`, multi-repo, ORCH-6)
Маппинг **Plane project id → (repo, work_item_prefix, name)**. Позволяет одному
оркестратору обслуживать несколько репозиториев, не путая их.
```python
@dataclass(frozen=True)
class ProjectConfig:
plane_project_id: str # uuid Plane-проекта (ключ реестра)
repo: str # имя gitea-репо (= папка в /repos)
work_item_prefix: str # ET / ORCH
name: str # человекочитаемое
```
Резолверы:
- `get_project_by_plane_id(uuid) -> ProjectConfig | None` — для фильтра/резолва в plane-webhook.
- `get_project_by_repo(repo) -> ProjectConfig | None` — когда известен только repo (gitea-webhook, plane_sync).
- `known_plane_project_ids() -> set[str]` — множество разрешённых проектов (фильтр).
**Источник конфигурации:** env `ORCH_PROJECTS_JSON` (JSON-массив `ProjectConfig`).
Если пусто/битый JSON — используется встроенный дефолт-реестр (enduro-trails + orchestrator),
чтобы система работала из коробки. Парсинг устойчив: битые записи пропускаются,
полностью невалидный JSON → fallback на дефолт.
Следствия multi-repo:
- **repo per project:** `repo = get_project_by_plane_id(project_id).repo` вместо хардкода `default_repo`.
- **prefix per project:** `get_next_work_item_id(repo, prefix)` нумерует независимо — `ORCH-001` vs `ET-010` (`src/db.py`).
- **plane_sync в правильный проект:** state/comment пишутся в Plane-проект самой задачи (резолв по repo через `get_project_by_repo`), а не в единственный хардкоженный `PROJECT_ID` (обратная совместимость сохранена дефолтом на enduro).
- **gitea-webhook:** push в repo вне реестра → `ignored` (не триггерит конвейер).
#### Gitea Webhook (`src/webhooks/gitea.py`)
- **push** — проверяет наличие артефактов (docs/, src/), продвигает стадию
- **pull_request\*** (wildcard) — обрабатывает review approved/rejected, PR merge
- **status** — CI green/failure, продвигает development → review
### 2. State Machine (`src/stages.py`)
Линейный пайплайн с одним возможным откатом (review → development):
```
STAGE_TRANSITIONS = {
created: → analysis (agent: None)
analysis: → architecture (agent: architect, QG: check_analysis_approved)
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-staging (agent: deployer, QG: check_tests_passed)
deploy-staging: → deploy (agent: deployer, QG: check_staging_status)
deploy: → done (agent: None, QG: None)
}
```
### 3. Quality Gates (`src/qg/checks.py`)
| Check | Метод проверки |
|-------|---------------|
| check_analysis_approved | Filesystem: 4 файла + :approved: comment в Plane |
| check_architecture_done | Filesystem: ADR dir или infra-requirements.md |
| check_tests_local | Оркестратор сам гоняет `make test` в **worktree задачи** `/repos/_wt/<repo>/<branch>` (judge по exit-code). Заменил check_ci_green: Gitea CI не сконфигурирован. Worktree-изоляция → безопасно при параллельных задачах (ORCH-2 / S-4). |
| check_reviewer_verdict | Filesystem: читает `verdict: APPROVED\|REQUEST_CHANGES` из YAML-frontmatter `12-review.md` (только машиночитаемое поле, не подстроки в тексте) |
| check_tests_passed | Filesystem: test-report.md содержит "PASS" |
| check_ci_green | (legacy) Gitea API: GET /commits/{branch}/status — больше не используется как QG развития |
| check_review_approved | (legacy) Gitea API: GET /pulls/{n}/reviews — не используется в STAGE_TRANSITIONS |
### 4. Agent Launcher (`src/agents/launcher.py`)
Запускает Claude CLI как subprocess:
```bash
claude.exe --print --system-prompt --allowedTools Read,Write,Edit,Bash
```
Каждый запуск:
1. Записывает run в DB (agent_runs)
2. Запускает subprocess. **stdout/stderr перенаправляются СРАЗУ в файл `/app/data/runs/{id}.log` на уровне ОС** (Popen `stdout=log_fh`). Никакого PIPE в памяти оркестратора → нет PIPE-deadlock, нет потока-читателя, нет зомби (B-2).
3. Стартует **watchdog thread** (timeout 30 мин → SIGKILL по pid)
4. Стартует **monitor thread**: `proc.wait()` (гарантированный reap → реальный exit_code в БД) → закрывает log_fh → git commit/push → auto-advance
### 5. Auto-advance (`launcher._try_advance_stage`)
После успешного завершения агента:
1. Определяет текущую стадию задачи
2. Проверяет QG для выхода из стадии
3. Если QG пройден — продвигает стадию
4. Запускает следующего агента (если определён)
Примечание: переход `review → testing` использует `check_reviewer_verdict` (читается из frontmatter `12-review.md`); `development → review``check_tests_local` (оркестратор сам прогоняет тесты, не зависит от Gitea CI).
### 6. Review Bounce
При REQUEST_CHANGES:
1. Считает количество developer runs для задачи
2. Если < MAX_DEV_RETRIES (3) — откатывает в development, перезапускает developer
3. Если >= MAX_DEV_RETRIES — эскалация (логирование + уведомление)
## Database Schema
```sql
-- Задачи
CREATE TABLE tasks (
id INTEGER PRIMARY KEY,
work_item_id TEXT, -- Plane issue identifier (e.g. "ET-006")
plane_issue_id TEXT, -- Plane UUID
repo TEXT,
branch TEXT,
stage TEXT DEFAULT 'created',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Запуски агентов
CREATE TABLE agent_runs (
id INTEGER PRIMARY KEY,
task_id INTEGER REFERENCES tasks(id),
agent TEXT, -- analyst/architect/developer/reviewer/tester
started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
finished_at TIMESTAMP,
exit_code INTEGER,
output_path TEXT -- /app/data/runs/{id}.log
);
-- Сырые события
CREATE TABLE events (
id INTEGER PRIMARY KEY,
source TEXT, -- plane/gitea
event_type TEXT,
payload TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
## Deployment
### Docker Compose
```yaml
services:
orchestrator:
build: .
container_name: orchestrator
restart: unless-stopped
network_mode: host
volumes:
- ./data:/app/data # SQLite + logs
- /home/slin/repos:/repos # Git repositories
- /var/run/docker.sock:/var/run/docker.sock # Docker CLI
- claude-code:/opt/claude-code:ro # Claude CLI binary
- /home/slin/.claude:/home/slin/.claude # Claude config
env_file: .env
group_add: ["999"] # docker group
```
### Dockerfile
- Base: python:3.12-slim
- Docker CLI (sibling containers)
- **tini** как PID 1 (proper zombie reaping)
- `git config --global safe.directory '*'`
- ENTRYPOINT: tini → uvicorn
## Потоки данных
### Happy path (ET-006 пример)
```
1. Plane webhook: work_item.created → task created, analyst launched
2. Analyst: пишет BRD/TRZ/AC/TestPlan → git push docs/
3. Plane comment :approved: → QG check_analysis_approved → PASS
4. Auto-advance: analysis → architecture, architect launched
5. Architect: пишет ADR, infra-requirements → git push docs/
6. Gitea push webhook: ADR detected → QG check_architecture_done → PASS
7. Auto-advance: architecture → development, developer launched
8. Developer: пишет код src/ + tests/ → git push, creates PR
9. Gitea status webhook: CI green → QG check_ci_green → PASS
10. Auto-advance: development → review, reviewer launched
11. Reviewer: оставляет review (APPROVED или REQUEST_CHANGES)
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-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
```
11. Reviewer: REQUEST_CHANGES
12. Gitea PR webhook: review_state=REQUEST_CHANGES, stage=review
13. Rollback: review → development, developer relaunched (attempt N/3)
14. Developer: фиксит замечания → git push
15. CI green → development → review, reviewer relaunched
16. Reviewer: APPROVED → continue happy path
```
## Resilience
| Механизм | Описание |
|----------|----------|
| Watchdog | Каждый агент: timeout 30 мин → SIGKILL + exit_code=-9 |
| safe.directory | git операции работают в любой директории |
| Max retries | Developer: max 3 попытки, затем эскалация |
| Zombie-free | stdout идёт сразу в файл + monitor `proc.wait()` → процесс всегда reap'нут (B-2) |
| Orphan recovery | При старте: orphan-run'ы (finished_at IS NULL, старше 35 мин) помечаются exit=-1 с per-run warning + Telegram-уведомлением «нужна ручная проверка» (M-1) |
## Агенты
Каждый агент — Claude CLI с:
- **System prompt**: `.openclaw/agents/{role}.md` (в репозитории)
- **Task file**: `.task-{suffix}.md` — генерируется orchestrator **прямой записью в worktree задачи** `/repos/_wt/<repo>/<branch>/` (B-1, без docker; ORCH-2 — в изолированную рабочую копию, не в shared `/repos/<repo>`). В `.gitignore` репозитория проекта (рантайм-артефакт, не коммитится).
- **Tools**: Read, Write, Edit, Bash
- **Output**: `--print` mode (весь вывод в stdout после завершения)
| Агент | Артефакты | Время (типичное) |
|-------|-----------|-------------------|
| analyst | BRD, TRZ, AC, TestPlan | 5-10 мин |
| architect | ADR, infra-requirements, tech-risks | 5-10 мин |
| developer | src/, tests/, PR | 15-30 мин |
| reviewer | review report, PR review | 3-5 мин |
| tester | test-report.md, e2e results | 10-25 мин |
| deployer | merge PR + SSH deploy-hook + smoke | 5-10 мин |
## Изоляция через git worktree (ORCH-2 / S-4)
Каждая задача (= одна git-ветка) работает в **изолированной git worktree**, а не в общем
`/repos/<repo>`. Это убирает гонки `git checkout`, когда две задачи активны одновременно.
```
/repos/<repo> ← основной clone (fetch / управление worktree, read-only запросы)
/repos/_wt/<repo>/<safe-branch> ← worktree конкретной задачи (рабочая копия агента)
```
Модуль `src/git_worktree.py`:
- `get_worktree_path(repo, branch)` — путь worktree (не создаёт).
- `ensure_worktree(repo, branch)` — создаёт (или переиспользует) worktree на нужной ветке;
для новой ветки создаёт её от `origin/main`. Возвращает путь.
- `remove_worktree(repo, branch)` — опциональная очистка при `done`.
Где используется worktree:
- **launcher**: агент запускается с `cd <worktree>` (без `git checkout` в cmd); task-файл
пишется в worktree; commit/push в `_monitor_agent` идут в worktree.
- **qg/checks**: чтение артефактов агента (`check_analysis_complete`, `check_architecture_done`,
`check_tests_passed`, `check_reviewer_verdict`) и `check_tests_local` (`make test`) — из worktree.
Артефакт-функции принимают опциональный `branch`; без него падают на shared `/repos/<repo>`
(обратная совместимость).
- **webhooks/gitea**: `git branch -r --contains <sha>` оставлен в основном clone — это
**read-only** запрос (нет checkout/мутации), гонок не создаёт.
> Один branch может быть checked out только в одной worktree одновременно —
> это и есть нужное свойство: одна задача = одна ветка = одна worktree.
## Известные ограничения
- ~~Shared `/repos` checkout (гонки при параллельных задачах).~~ **РЕШЕНО (ORCH-2 / S-4):**
git worktree per task/branch — см. раздел «Изоляция через git worktree» ниже.
- ~~In-process daemon-потоки (рестарт → сироты, потеря работы).~~ **РЕШЕНО (ORCH-1 / F-2b):**
персистентная очередь jobs + фоновый воркер — см. раздел «Очередь задач (ORCH-1)» ниже.
Daemon-потоки monitor/watchdog остаются для одного запущенного агента, но при
рестарте его job возвращается в `queued` (queue-recovery) и переподхватывается.
## Очередь задач (ORCH-1 / F-2b)
Раньше webhook-хэндлер **синхронно** спавнил `subprocess.Popen` + 2 daemon-thread
прямо в процессе uvicorn (8 точек вызова). Рестарт = сироты + потеря работы,
нет лимита параллелизма, нет ретраев.
### Flow
```
webhook (plane/gitea) background thread (queue_worker)
│ │
enqueue_job() ---> [ jobs table ] <--- claim_next_job() (atomic queued->running)
(мгновенный status=queued │
ответ 200) launch_job(job)
AgentLauncher._spawn (Popen claude)
_monitor_agent (proc.wait, commit/push,
│ advance stage)
_finalize_job:
exit 0 -> mark_job done
exit !=0 & attempts<max -> requeue (queued)
exit !=0 & attempts>=max -> failed + Telegram
```
### Таблица `jobs`
| Колонка | Назначение |
|--------|------------|
| `status` | `queued``running``done` \| `failed` |
| `attempts` / `max_attempts` | счётчик попыток (инкремент при claim) / лимит ретраев (default 2) |
| `run_id` | FK на `agent_runs.id` после старта |
| `task_content` | ТЗ, которое пишется в task-файл агента |
| `error` | последняя ошибка |
`idx_jobs_status (status, id)` — быстрый FIFO-выбор queued.
### Атомарный claim
`claim_next_job()` делает `SELECT queued ORDER BY id LIMIT 1``UPDATE ... WHERE id=? AND
status='queued'` и проверяет `rowcount`. При гонке двух тиков лишь один UPDATE
переведёт строку в `running` (rowcount==1); проигравший берёт следующий job.
### Queue-recovery (рестарт-safe)
В `main.py` lifespan **после** M-1 orphan-recovery вызывается `requeue_running_jobs()`:
jobs со статусом `running` (воркер умёр на рестарте) → возвращаются в `queued`.
Потом стартует воркер; на shutdown — `worker.stop()` (Event.set + join).
### Конфиг
- `ORCH_MAX_CONCURRENCY` (default 1) — лимит параллельных jobs.
- `ORCH_QUEUE_POLL_INTERVAL` (default 2.0) — период опроса.
- `ORCH_AGENT_MODEL_DEFAULT` / `ORCH_AGENT_MODEL_<AGENT>` (ORCH-41) — модель агентов; дефолт `claude-opus-4-8`.
- `ORCH_AGENT_EFFORT_DEFAULT` / `ORCH_AGENT_EFFORT_<AGENT>` (ORCH-41) — режим `--effort` (low|medium|high|xhigh|max).
- `ORCH_AGENT_FALLBACK_MODEL` (ORCH-41) — опц. `--fallback-model` при overloaded.
- per-project override: `agent_models` / `agent_efforts` в `ORCH_PROJECTS_JSON`; резолверы `resolve_agent_model` / `resolve_agent_effort` (project > per-agent env > default > пусто).
Наблюдаемость: `GET /queue` — counts по статусам + последние 10 jobs.
> Совместимость: `launcher.launch()` (прямой синхронный запуск, `job_id=None`)
> сохранён для обратной совместимости. Очередь использует `launch_job()`;
> оба разделяют `_spawn()` (Popen-логика B-2 не изменена).
- **Gitea CI не настроен.** QG развития теперь локальный (`check_tests_local`);
Gitea CI-статусы не являются authoritative и не блокируют pipeline.
- **Docker внутри контейнера orchestrator НЕДОСТУПЕН.** Деплой идёт только через
SSH-хук `enduro-deploy-hook.sh` на хосте.