Files
orchestrator/docs/architecture/internals.md
claude-bot 98b47fe021
Some checks failed
CI / test (push) Failing after 14s
CI / test (pull_request) Failing after 13s
feat(preflight): catch logged-out auth and treat empty result as failure
ORCH-044 closes two blind spots that let a single de-authenticated agent
stall the shared queue for all projects:

P1 — preflight auth gate. `claude --version` answers even when logged out,
so version-only preflight was blind to auth. Adds a token-free, network-free
check of <AGENT_HOME>/.claude/.credentials.json: missing/unreadable/no-oauth
or an expired `claudeAiOauth.expiresAt` (epoch ms, vs now + skew) => preflight
FAIL; absent expiry => OK (no false positives). Result is cached on the same
preflight_cache_ttl. Post-factum safety net: launcher detects auth markers
("not logged in" / "/login" / "unauthorized" / 401) in the run log and resets
the preflight cache so the next tick re-evaluates auth. Auth failure is a gate,
not a transient — it does not spin the circuit breaker. Emergency toggle
ORCH_PREFLIGHT_CHECK_AUTH=false restores version-only behaviour.

P3 — empty log / no result-JSON => job failed. exit_code==0 with an empty or
JSON-less run log no longer counts as success: a separate result_ok flag gates
stage advance + usage comments, fires a Telegram alert, and routes the job
through the normal transient/permanent failure path (exit_code integrity in
agent_runs preserved).

Scope: P2 (--effort) is intentionally excluded and tracked in ORCH-50.

New settings: ORCH_PREFLIGHT_CHECK_AUTH, ORCH_CLAUDE_CREDENTIALS_PATH,
ORCH_AUTH_EXPIRY_SKEW_SECONDS. Docs updated (INFRA.md, internals.md, CHANGELOG).

Refs: ORCH-044

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 08:11:27 +00:00

23 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 → валидация результата (ORCH-044) → git commit/push → auto-advance

Валидация результата (ORCH-044, P3). exit_code==0 сам по себе НЕ считается успехом: claude может «быстро умереть» (разлогинен / флаг гасит stdout), оставив пустой или JSON-less лог, но выйдя с кодом 0 — раньше это было неотличимо от успеха (done + auto-advance по пустому результату). Теперь _monitor_agent вызывает _validate_result(output_path):

  • лог отсутствует / пустой (0 байт или только whitespace) ⇒ невалиден;
  • нет парсящегося trailing result-JSON (тот же контракт, что usage-учёт — usage._extract_last_json_object) ⇒ невалиден;
  • хелпер защитный (never-raise); при собственной ошибке — fail-safe в сторону провала.

success = (exit_code==0 AND result_ok). Реальный exit_code пишется в agent_runs без искажения; на решение done/fail влияет отдельный флаг result_ok (не подменённый код выхода). Только при success: постится «успешный» status-коммент и вызывается _try_advance_stage. При exit_code==0 AND not result_ok: шлётся Telegram-алерт о пустом/невалидном результате, стадия НЕ двигается, а _finalize_job(result_ok=False) маршрутизирует job в провал (empty run log / no result JSON): по умолчанию permanent (attempts<max ⇒ requeue, иначе failed+алерт), transient-маркер в логе уводит в transient-путь. Итог: exit_code==0 всегда завершается терминально/ретраябельно (done|failed|queued) — путь «быстрая смерть с exit 0 → вечный running» закрыт.

Постфактум auth-детекция (ORCH-044, P1b). В пути провала _handle_auth_marker(log) ищет маркер разлогина (not logged in / please run /login / unauthorized / 401) и при совпадении сбрасывает preflight-кеш (preflight.reset_cache()), чтобы следующий тик воркера переоценил auth проактивно. Auth-провал НЕ transient и НЕ крутит circuit breaker.

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 — эскалация (логирование + уведомление)

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)
Preflight auth-гейт (ORCH-044) Перед клеймом: os.path.exists(bin) + claude --version + token-free auth (чтение .credentials.json + expiresAt); разлогинен / протух ⇒ job не клеймится. Постфактум-маркер not logged in сбрасывает кеш. Тумблер ORCH_PREFLIGHT_CHECK_AUTH. Детали — INFRA.md
Пустой результат = провал (ORCH-044) exit 0 с пустым/JSON-less логом ⇒ failed/retry + алерт, без auto-advance (см. §4 «Валидация результата»)

Агенты

Каждый агент — 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(result_ok):
                                            exit 0 & result_ok -> mark_job done
                                            else (exit!=0 ИЛИ пустой результат):
                                              attempts<max -> requeue (queued)
                                              attempts>=max -> failed + Telegram

ORCH-044 (P3): result_ok отражает валидность run-лога (непустой + есть result-JSON). exit 0 с пустым/невалидным результатом идёт в ветку провала, НЕ в done (см. §4 «Валидация результата»).

Таблица jobs

Колонка Назначение
status queuedrunningdone | 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 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).

Конфиг

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