Tier-2 reaped a LIVE, still-finalizing monitor: _monitor_agent writes agent_runs.exit_code FIRST, then does git push / PR / Plane comments before _finalize_job, and the agent pid is already dead in that window — so the old "exit_code recorded -> reap now" had no grace and could race a healthy job. Worse, _reap_known_outcome ran the advance (advance_stage -> enqueue_job) BEFORE the atomic claim, so a reaper that lost the race had already enqueued the next stage (dup advance / dup enqueue), violating ADR-001 Р-1. Fix: - Tier-2 grace: reap only once agent_runs.exit_code has been recorded for >= reaper_finalize_grace_s (new setting, default 300s; > max finalization window). A live finalizing monitor is never reaped (FR-1.3/AC-3). New finished_age_s column computed in get_running_jobs. - claim-before-act for exit0: evaluate the canonical QG READ-ONLY (the reconciler pattern) to choose the terminal status, then atomically claim 'done' FIRST; only the claim winner runs the advance. A loser performs no side effects -> no dup advance / dup enqueue. Docs (golden source) updated in the same change: ADR-001, global adr-0011, README, internals, .env.example, CHANGELOG (also fixes the P3 broken adr-0011 link). New tests cover the grace window, lost-claim no-side-effects, and the already-advanced idempotent path. Refs: ORCH-065 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
27 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 → git commit/push → auto-advance
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 — эскалация (логирование + уведомление)
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
-- Задачи
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:
--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:
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 после старта |
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 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).
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-1 —
jobs.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-gracereaper_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<max→queued, иначе 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на хосте.