19 KiB
Аудит мультиагентного оркестратора — 2026-06-02
Контекст: глубокое ревью после ET-009, где автономность фактически отсутствовала (0 из 6 этапов запустились сами; всё через base64-ручник).
Объём аудита:
- Код оркестратора:
launcher.py,stages.py,qg/checks.py,webhooks/{plane,gitea}.py,db.py,config.py,main.py - Инфраструктура:
Dockerfile,docker-compose.yml - Агентские инструкции:
analyst/architect/developer/reviewer/tester/deployer.md - Состояние: orchestrator DB (tasks + agent_runs), Plane
Главный вывод из БД agent_runs:
ET-009 (task 16): analyst/developer/reviewer — все exit=None (зомби, не дождались)
ET-008 (task 15): architect exit=-1 ×2, dev/reviewer exit=-9 (timeout kill)
Закономерность: НИ ОДИН прогон последних 2 задач не завершился чисто (exit=0 кроме analyst)
🔴 BLOCKER-уровень (убивают автономность)
B-1. Нет docker бинарника в контейнере → launcher ломается в корне
Где: docker-compose.yml монтирует /var/run/docker.sock, но в Dockerfile ставится только openssh-client git. Бинарника docker НЕТ (проверено: docker exec orchestrator which docker → пусто).
Последствие: launcher._write_task_file() вызывает docker run --rm -i python:3.12-slim ... чтобы записать .task-*.md на хост. Но docker команды нет → запись падает или таск-файл не обновляется → агент читает СТАРЫЙ .task-*.md от предыдущей задачи.
Это причина бага из ET-008: architect v1 упал, прочитав .task-arch.md от другой задачи.
Фикс (3 варианта, по возрастанию чистоты):
- F-1a (быстро): добавить в Dockerfile
docker-ce-cli(только клиент):RUN apt-get update -qq && apt-get install -y -qq ca-certificates curl gnupg openssh-client git \ && install -m 0755 -d /etc/apt/keyrings \ && curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc \ && echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian bookworm stable" > /etc/apt/sources.list.d/docker.list \ && apt-get update -qq && apt-get install -y -qq docker-ce-cli \ && rm -rf /var/lib/apt/lists/* - F-1b (чище): не использовать docker для записи файла вообще.
_write_task_fileпишет на хост-путь напрямую — но контейнер не видит host-путь как host-путь. Решение: writing в/repos/<repo>/.task-*.md(это уже смонтированный volume!) обычнымopen(... ,"w"). Это самый правильный фикс — убирает docker-зависимость полностью. - F-1c: mount docker CLI бинарник с хоста (как уже сделано для node/claude):
-v /usr/bin/docker:/usr/bin/docker:ro.
Рекомендация: F-1b. repos_dir=/repos уже примонтирован read-write. Запись .task файла должна идти туда напрямую — никакого docker.
B-2. subprocess.Popen + потоки = зомби-процессы
Где: launcher.launch() → subprocess.Popen(["bash","-c",cmd], stdout=PIPE, stderr=STDOUT), дальше _monitor_agent в daemon-потоке делает proc.wait().
Проблемы:
- PIPE deadlock. Claude CLI пишет много в stdout.
_monitor_agentчитает черезselect+readline, но если буфер переполняется ДО старта чтения (между Popen и стартом потока) — claude блокируется на write. При этом select-цикл ждёт 10с интервалами, а startup-timeout 120с убивает «молчащий» процесс, который на самом деле заблокирован на PIPE. - Daemon-потоки.
_monitor_agentи_watchdog—daemon=True. При рестарте uvicorn / краше — потоки умирают, а дочерние claude-процессы остаются осиротевшими (зомбиZ, parent=1). Никто не делаетwait()→ зомби висят. exit=Noneв БД. Подтверждение: все ET-009 прогоны имеютexit_code=None—_monitor_agentпоток не довёлproc.wait()до записи в БД.
Фикс:
- F-2a: не держать PIPE в памяти процесса-оркестратора. Перенаправлять stdout/stderr claude СРАЗУ в файл на уровне ОС:
subprocess.Popen(..., stdout=open(logpath,"w"), stderr=STDOUT). Тогда нет PIPE-deadlock, нет потока-читателя. Мониторинг — отдельным лёгкимpoll()поproc.pidили по mtime лог-файла. - F-2b (правильно): вынести запуск агентов из in-process потоков в очередь задач (RQ / Celery / простой SQLite-backed worker loop). FastAPI webhook только кладёт job в очередь; отдельный worker-процесс запускает агента, ждёт, пишет результат. Рестарт uvicorn не убивает запущенных агентов; worker переподхватывает.
- F-2c: использовать
subprocess.run(timeout=...)в worker'е вместо Popen+поток — синхронно, с гарантированнымwait(), без зомби.
Рекомендация: F-2a немедленно (1 строка убирает deadlock), F-2b как целевая архитектура.
B-3. Per-stage .task-*.md не пересоздаются надёжно
Где: launcher.launch(task_content=...) пишет таск-файл ТОЛЬКО если task_content передан. Но из-за B-1 запись падает молча. А агент в cmd читает "$(cat {task_file})" — если файл старый, агент работает по чужому ТЗ.
Дополнительно: каждый агент имеет ОТДЕЛЬНЫЙ task-файл (.task-arch.md, .task-dev.md...). Они коммитятся в репо (видны в git diff ET-009!) и тащатся между задачами. Это грязь в git-истории фичеветки.
Фикс:
- Писать таск-файл напрямую (см. F-1b), с проверкой что запись удалась (raise если нет).
.task-*.mdдобавить в.gitignoreрепозитория enduro-trails — это рантайм-артефакт, не должен коммититься.- Либо вообще не использовать файлы: передавать ТЗ агенту через
--append-system-promptили stdin, не черезcat файла.
🟠 SERIOUS (ложные сигналы, неверная логика)
S-1. Нет CI в Gitea → check_ci_green всегда false → ложные алерты
Где: stages.py: переход development → review имеет qg: check_ci_green. check_ci_green дёргает Gitea /commits/{branch}/status. В Gitea CI не настроен → status пустой/404 → возвращает (False, "...").
Последствие: webhook handle_ci_status никогда не получает state=success (CI нет), поэтому автопереход на review НЕ происходит сам. А handle_push/прочее шлёт notify_error про «CI failed» на каждый push.
Фикс:
- S-1a: настроить минимальный CI в Gitea Actions (lint+test) — тогда статусы реальные. Это и есть «правильно».
- S-1b (если CI пока не нужен): заменить QG
check_ci_greenна файловый/локальный прогон тестов: оркестратор сам запускаетmake testв/repos/<repo>и判ает по exit-code. Не зависит от Gitea. - S-1c: временно сделать
check_ci_green→ если CI не сконфигурирован (404/empty), не блокировать, а логироватьSKIPPEDи пропускать. Сейчас он жёстко false.
Рекомендация: S-1b — оркестратор сам гоняет тесты, детерминированно.
S-2. Deployer запускается как агент, но реально не может в docker
Где: stages.py testing → deploy агент=deployer. deployer.md использует Bash (git, curl, docker). Но claude-агент запускается в контексте, где docker недоступен (та же проблема B-1) ИЛИ на хосте по SSH — неоднозначно.
Наблюдение на ET-009: deployer смержил PR (через curl Gitea API — это сработало), но завис на docker compose build (docker недоступен). Деплой доделан вручную.
Хорошая новость: deployer.md УЖЕ написан правильно — деплой идёт через SSH-хук enduro-deploy-hook.sh на хост, а не через локальный docker. Проблема в том, что хук, видимо, не отработал/не существует.
Фикс:
- Проверить наличие
/home/slin/bin/enduro-deploy-hook.shи что он делает build+up+collector+smoke. - Деплой-хук — единственное место, где должен жить docker. Агент только дёргает SSH.
- Убрать
dockerиз дозволенных Bash deployer'а в промпте, чтобы не было соблазна.
S-3. Rollback деплоера портит общий /repos checkout
Где: deployer.md шаг 6 rollback: git checkout $LAST_TAG. Это в общем /repos/enduro-trails, который шарят ВСЕ агенты и сам оркестратор.
Последствие: detached HEAD / переключение тега ломает состояние репо для следующих операций, конкурентных задач.
Фикс: rollback должен происходить на деплой-стороне (в деплой-хуке через теги/образы), НЕ через git checkout в shared-репо. Деплой и исходники должны быть разнесены.
S-4. Shared mutable /repos checkout = гонки между задачами
Где: все агенты, webhooks, _monitor_agent делают git checkout <branch> в одном /repos/<repo>. Если две задачи активны — checkout одной перетирает рабочую копию другой.
Последствие: при параллельных задачах — хаос. На ET-009 это проявилось как «два коллектора» и путаница веток.
Фикс: git worktree per task/branch. git worktree add /repos/_wt/<branch> <branch> — изолированная рабочая копия на задачу. Агент работает в своём worktree. Убирает все гонки checkout.
S-5. check_reviewer_verdict — хрупкий парсинг "APPROVED"/"REQUEST_CHANGES"
Где: qg/checks.py читает первые 5000 байт 12-review.md, ищет подстроки REQUEST_CHANGES/APPROVED/LGTM в upper-case.
Проблема: если в отчёте есть таблица «F-01 ... APPROVED/REQUEST_CHANGES» как заголовки колонок (как в реальном ET-009 round-2 отчёте!) — парсер поймает оба и вернёт по порядку проверки. REQUEST_CHANGES проверяется первым → ложный fail при APPROVED-отчёте, где упомянут термин.
Фикс: ввести строгий машиночитаемый verdict. Reviewer ОБЯЗАН писать в YAML-фронтматтер verdict: APPROVED|REQUEST_CHANGES (как tester уже делает — verdict: PASS!). QG читает только фронтматтер, не весь текст.
🟡 MEDIUM (надёжность, наблюдаемость)
M-1. Orphan-recovery слишком грубый
Где: main.py lifespan: UPDATE agent_runs SET exit_code=-1 WHERE finished_at IS NULL AND started_at < now-35min. Это маскирует зомби как «exit=-1», но не убивает реальные процессы и не перезапускает задачу. Просто «забывает» о них.
Фикс: при recovery — реально проверять/убивать pid, и ставить задачу в blocked с уведомлением, а не молча списывать.
M-2. AGENT_TIMEOUT=1800 хардкод, watchdog SIGKILL без cleanup
Где: _watchdog спит 1800с, потом os.kill(pid, SIGKILL). SIGKILL не даёт claude корректно завершить запись файлов → возможны полу-записанные артефакты. Нет различения «медленный Opus» vs «завис».
Фикс: SIGTERM сначала (graceful), SIGKILL через grace-period. Таймаут — конфигурируемый per-agent (Opus-ревью дольше).
M-3. Дубль логики автоперехода: launcher._try_advance_stage И webhooks/plane._try_advance_stage
Где: есть ДВА почти одинаковых _try_advance_stage — в launcher и в plane webhook. Разъезжаются по фиксам (Task 6/7/8 только в launcher).
Фикс: единый stage-engine модуль. Webhook и launcher вызывают одну функцию. Сейчас риск рассинхрона поведения.
M-4. _auto_merge_pr — мёртвый код
Где: launcher._auto_merge_pr определён, но (по заметкам 01.06) хардкод убран, merge теперь делает deployer-агент. Метод висит неиспользуемым.
Фикс: удалить мёртвый код или явно задокументировать что не используется.
M-5. Секреты и пути захардкожены в промптах
Где: deployer.md, агентские md содержат 82.22.50.71, admin, URL'ы, имена хуков. При смене инфры — править в куче мест.
Фикс: инфра-параметры — через env/конфиг, в промпт подставлять. Промпт = роль, не инвентарь.
M-6. work_item_id генерится из DB max, не из Plane sequence
Где: db.get_next_work_item_id берёт последний из tasks и +1. Но Plane sequence независим. Возможен рассинхрон ET-номеров между Plane и оркестратором.
Фикс: источник правды для номера — Plane sequence_id, мапить на ET-NNN детерминированно.
M-7. Нет идемпотентности webhook'ов
Где: Gitea/Plane могут ретраить webhook. events логируются, но нет дедупа по event-id. Повторный webhook → повторный запуск агента.
Фикс: дедуп по delivery-id/event-uuid, хранить обработанные, игнорировать повторы.
🟢 LOW (косметика, гигиена)
- L-1.
STAGE_TRANSITIONSкомментарий «agent launched when entering NEXT stage» путаный —get_agent_for_stageвозвращает агента ТЕКУЩЕГО перехода. Нейминг сбивает. - L-2. Логи агентов в
/app/data/runs/{run_id}.log— но при зомби они пустые. Нет ротации. - L-3. Магические строки эмодзи как
\u2705в коде вместо констант. - L-4.
{src— мусорная папка в корне репо орка (видно вls). - L-5. Нет тестов на launcher (только webhooks/qg). Самая хрупкая часть — без покрытия.
📋 Приоритизированный план устранения
Спринт 1 — «вернуть автономность» (критично)
- B-1 (F-1b):
_write_task_fileпишет в/repos/<repo>/.taskнапрямую черезopen(). Убрать docker-зависимость. (1-2ч) - B-2 (F-2a): Popen stdout → файл напрямую (не PIPE). Убрать поток-читатель. (1-2ч)
- B-3:
.task-*.mdв.gitignore, проверка успешной записи. (30мин) - S-5: Reviewer пишет
verdict:во фронтматтер; QG читает только его. (1ч + правка reviewer.md)
→ После спринта 1: pipeline должен пройти ET-009-подобную задачу БЕЗ ручника.
Спринт 2 — «детерминизм и развязка»
- S-1b: QG-тесты гоняет сам оркестратор (
make test), не зависим от Gitea CI. (2-3ч) - S-4: git worktree per branch — изоляция задач. (3-4ч)
- S-2/S-3: деплой только через SSH-хук, rollback на деплой-стороне, не в shared-репо. Проверить/написать
enduro-deploy-hook.sh. (2-3ч) - M-3: единый stage-engine, убрать дубль
_try_advance_stage. (2ч)
Спринт 3 — «надёжность»
- F-2b: очередь задач (SQLite-worker) вместо in-process daemon-потоков. (день)
- M-1, M-2: нормальный orphan-recovery + graceful timeout. (2-3ч)
- M-7: идемпотентность webhook'ов. (2ч)
- L-5: тесты на launcher. (полдня)
Резюме одной строкой
Корень всех бед — два бага: (1) нет docker-бинарника → запись таск-файлов падает молча; (2) Popen+PIPE+daemon-поток → зомби и потеря exit-кода. Оба чинятся за полдня и убирают 90% ручника. Остальное — развязка задач (worktree), детерминизм QG (свои тесты вместо Gitea CI) и машиночитаемые вердикты.