# Архитектура 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//` (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 — эскалация (логирование + уведомление) ### 7. Live Telegram tracker (`src/notifications.py`) Вместо ~15 отдельных сообщений на задачу оркестратор держит **ОДНУ** live-карточку на задачу (`update_task_tracker`), которая обновляется на каждом переходе стадии. Текст рендерится статически из БД (`render_task_tracker`: стадии, токены, стоимость, BRD-подтверждение, итоги). Карточка всегда тихая (`disable_notification=True`); отдельные пинги шлют только `notify_approve_requested` / `notify_error`. `message_id` хранится в `tasks.tracker_message_id`; helpers `get_tracker_message_id` / `set_tracker_message_id`. Контракт всего компонента — **never raises**. **Режимы (ORCH-042, `ORCH_TRACKER_MODE` → `Settings.tracker_mode`).** Резолвится в `update_task_tracker` (case-insensitive, trim); всё, что ≠ `"bump"` (включая пустое/мусор/None), трактуется как `edit` → нулевая регрессия и безопасный фолбэк. Инвариант «одна карточка на задачу» сохраняется в обоих режимах. | Режим | Поведение при обновлении | |-------|--------------------------| | `edit` (дефолт) | первый вызов → `send_telegram` (тихо) + сохранение `message_id`; далее → `edit_telegram` на сохранённый id. Новое сообщение шлётся ТОЛЬКО при `EDIT_GONE` (удалено/старше 48ч/невалидный id). `EDIT_NOT_MODIFIED` / `EDIT_FAILED` → нового сообщения нет (анти-дубль). | | `bump` | карточка пересоздаётся внизу чата: best-effort `delete_telegram(старый_id)` → `send_telegram(text, disable_notification=True)` → `set_tracker_message_id(new_id)` **только** при успешном send (`new_mid is not None`). За один вызов — не более одного нового сообщения. | **`delete_telegram(message_id) -> bool`** (low-level, never raises). Семантика возврата — «исчезло ли старое сообщение»: - `ok:true` → `True`; - `ok:false` с маркерами `_DELETE_GONE_MARKERS` (`message to delete not found`, `message can't be deleted`, `message_id_invalid`) → `True` (старше 48ч / уже удалено — не транзиент); - прочий `ok:false` / 5xx / исключение (сеть/таймаут) → `False` + `logger.warning`; - нет токена/chat_id → `False`, HTTP не выполняется. Результат `delete_telegram` **не** блокирует отправку новой карточки (BR-6: delete-fail у сообщения >48ч → всё равно шлём новое); `False` означает лишь «старое, возможно, ещё живо» — будет вычищено повторной попыткой на следующем переходе. При транзиентном сбое send (`None`) указатель `tracker_message_id` **не** затирается (анти-затирание, симметрично edit-fallback). **Текст карточки (оба режима, ORCH-042):** метка `Подтверждение BRD` (была «Ревью БРД»); после прохождения approve-gate строка BRD начинается с ✅ (ветка ожидания сохраняет ⏸️/⏳); русские display-labels стадий (`Анализ / Архитектура / Разработка / Код ревью / Тестирование / Внедрение`); финальная строка `📦 Внедрено` (было `deployed`). Меняются только отображаемые строки — ключи стадий и имена агентов (завязаны на `_STAGE_ACTIVE_AGENT`, `last_done`, БД) не трогаются. ## 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///` (B-1, без docker; ORCH-2 — в изолированную рабочую копию, не в shared `/repos/`). В `.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/`. Это убирает гонки `git checkout`, когда две задачи активны одновременно. ``` /repos/ ← основной clone (fetch / управление worktree, read-only запросы) /repos/_wt// ← 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 ` (без `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/` (обратная совместимость). - **webhooks/gitea**: `git branch -r --contains ` оставлен в основном 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 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_` (ORCH-41) — модель агентов; дефолт `claude-opus-4-8`. - `ORCH_AGENT_EFFORT_DEFAULT` / `ORCH_AGENT_EFFORT_` (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` на хосте.