Files
orchestrator/docs/architecture/internals.md

29 KiB
Raw Blame History

Архитектура 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). Позволяет одному оркестратору обслуживать несколько репозиториев, не путая их.

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

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 → reviewcheck_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_MODESettings.tracker_mode; дефолт переключён edit → bump в ORCH-067). Резолвится в update_task_tracker (case-insensitive, trim); всё, что ≠ "bump" (включая пустое/мусор/None), трактуется как edit → безопасный фолбэк. Инвариант «одна карточка на задачу» сохраняется в обоих режимах.

Режим Поведение при обновлении
bump (дефолт, ORCH-067) карточка пересоздаётся внизу чата: best-effort delete_telegram(старый_id)send_telegram(text, disable_notification=True)set_tracker_message_id(new_id) только при успешном send (new_mid is not None). За один вызов — не более одного нового сообщения. Живая карточка всегда «догоняет» переписку.
edit первый вызов → send_telegram (тихо) + сохранение message_id; далее → edit_telegram на сохранённый id. Новое сообщение шлётся ТОЛЬКО при EDIT_GONE (удалено/старше 48ч/невалидный id). EDIT_NOT_MODIFIED / EDIT_FAILED → нового сообщения нет (анти-дубль).

delete_telegram(message_id) -> bool (low-level, never raises). Семантика возврата — «исчезло ли старое сообщение»:

  • ok:trueTrue;
  • 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, БД) не трогаются.

Строка Plane-статуса и кликабельный номер (ORCH-067, слой B — индикация). Под заголовком карточка несёт строку 📍 <Plane-статус> по модели ORCH-066. Источник — двухслойный, контракт never raises:

  • Оффлайн-ядро plane_status_label(task_row) — чистая функция БЕЗ сети: stage → статус (created→To Analyse, analysis→Analysis, architecture→Architecture, development→Development, review→Code-Review, testing→Testing, deploy→⏸ Awaiting Deploy, done→Done) + ⏸️ In Review из brd-часов (brd_review_started_at задан, …_ended_at пуст). Неизвестная/битая стадия → безопасный дефолт To Analyse.
  • Live-overlay _live_plane_branch_override — best-effort: дорисовывает ветви-статусы, неразличимые оффлайн (Needs Input / Blocked / Rejected / Cancelled / Deploying / Monitoring after Deploy), чтением живого Plane-статуса (fetch_issue_state с коротким tracker_live_status_timeout_s, TTL-кэш tracker_live_status_ttl_s, kill-switch tracker_live_status). Любой сбой / выключенный флаг / нехватка данных → оффлайн-метка; ⏸️ In Review (авторитет brd-часов) overlay не консультирует. Анти-false-positive: deploying/monitoring, алиасящие базовый UUID на проекте без выделенного статуса (enduro), не вызывают override.

Кликабельный номер задачи (ORCH-067). Номер в заголовке карточки И во всех уведомлениях орка, где упоминается work_item_id, — HTML-ссылка на issue в Plane через общий plane_issue_link / link_for (URL строит _plane_issue_url с loopback/workspace/project-гардами, переиспользуя резолв ORCH-017). Fail-safe: при нехватке любого из (web-base/не-loopback, workspace, project_id, plane_issue_id) → html.escape(work_item_id) без <a>; динамические части экранируются, <a>-разметка валидна под parse_mode=HTML. Алерты stage_engine/launcher/security_gate/reconciler переведены на link_for (резолвит repo+plane_issue_id из БД по task_id или work_item_id).

Database Schema

-- Задачи
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

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 queuedrunningdone | failed
attempts / max_attempts счётчик попыток (инкремент при claim) / лимит ретраев (default 2)
run_id FK на agent_runs.id после старта
pid (ORCH-065) pid агентского процесса (proc.pid из _spawn); liveness-сигнал для job-reaper. Добавляется _ensure_column (idempotent)
task_content ТЗ, которое пишется в task-файл агента
error последняя ошибка

idx_jobs_status (status, id) — быстрый FIFO-выбор queued.

Атомарный claim

claim_next_job() делает SELECT queued ORDER BY id LIMIT 1UPDATE ... 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).

Job-reaper (ORCH-065, рестарт НЕ требуется)

requeue_running_jobs() спасает ТОЛЬКО на старте процесса. Зомби-job, возникший без рестарта (умер monitor-поток/дочерний процесс, а сервис жив), оставался running навсегда и при max_concurrency=1 блокировал всю очередь. Фоновый daemon-поток src/job_reaper.py (каркас reconciler) периодически (reaper_interval_s) сканирует running-jobs и реапит «мёртвые»:

  • Tier-1jobs.pid мёртв (os.kill(pid,0)ProcessLookupError) на протяжении reaper_dead_ticks подряд тиков (анти-ложноположительность);
  • Tier-2у agent_runs[run_id] записан exit_code, а jobs.status ещё running. Окно неоднозначно: живой monitor пишет exit_code ПЕРВЫМ, затем git push/PR/Plane-комментарии (секунды-десятки секунд) и лишь потом _finalize_job; pid агента к этому моменту мёртв в обоих случаях. Поэтому Tier-2 реапит только после finalization-grace reaper_finalize_grace_s (finished_age_s >= grace) — живой финализирующий monitor НЕ реапится;
  • Tier-3 — backstop: job висит running дольше reaper_max_running_s.

Реап атомарен (UPDATE jobs SET ... WHERE id=? AND status='running' + rowcount, как claim_next_job) → совместим со стартовым requeue_running_jobs без двойной обработки. Действие — claim-before-act: для exit0 канонический QG оценивается read-only ПЕРЕД атомарным claim, затем claim done ПЕРВЫМ и только победитель claim делает _try_advance_stage (advance+enqueue) — проигравший (поздний monitor / стартовый requeue) не выполняет побочных эффектов (нет дубль-advance/-enqueue); источник истины — QG, не «exit0»; гейт красный или exit≠0/неизвестно → attempts<maxqueued, иначе failed+Telegram. Тот же поток на старте и периодически делает проактивный реклейм stale/dead merge-lease (merge_gate.py: pid_alive/reclaim_stale_lease). never-raise; kill-switch ORCH_REAPER_ENABLED / ORCH_LEASE_RECLAIM_ENABLED; снимок в GET /queue (блок reaper). Подробнее — adr-0011.

Конфиг

  • 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 на хосте.