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>
23 KiB
Архитектура 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-001vsET-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
Каждый запуск:
- Записывает run в DB (agent_runs)
- Запускает subprocess. stdout/stderr перенаправляются СРАЗУ в файл
/app/data/runs/{id}.logна уровне ОС (Popenstdout=log_fh). Никакого PIPE в памяти оркестратора → нет PIPE-deadlock, нет потока-читателя, нет зомби (B-2). - Стартует watchdog thread (timeout 30 мин → SIGKILL по pid)
- Стартует 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)
После успешного завершения агента:
- Определяет текущую стадию задачи
- Проверяет QG для выхода из стадии
- Если QG пройден — продвигает стадию
- Запускает следующего агента (если определён)
Примечание: переход review → testing использует check_reviewer_verdict (читается из frontmatter 12-review.md); development → review — check_tests_local (оркестратор сам прогоняет тесты, не зависит от Gitea CI).
6. Review Bounce
При REQUEST_CHANGES:
- Считает количество developer runs для задачи
- Если < MAX_DEV_RETRIES (3) — откатывает в development, перезапускает developer
- Если >= 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:
--printmode (весь вывод в 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РЕШЕНО (ORCH-2 / S-4): git worktree per task/branch — см. раздел «Изоляция через git worktree» ниже./reposcheckout (гонки при параллельных задачах).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 |
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на хосте.