Files
wiki/tasks/orchestrator/AUDIT_2026-06-02.md
2026-06-02 21:00:01 +03:00

19 KiB
Raw Blame History

Аудит мультиагентного оркестратора — 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().

Проблемы:

  1. PIPE deadlock. Claude CLI пишет много в stdout. _monitor_agent читает через select+readline, но если буфер переполняется ДО старта чтения (между Popen и стартом потока) — claude блокируется на write. При этом select-цикл ждёт 10с интервалами, а startup-timeout 120с убивает «молчащий» процесс, который на самом деле заблокирован на PIPE.
  2. Daemon-потоки. _monitor_agent и _watchdogdaemon=True. При рестарте uvicorn / краше — потоки умирают, а дочерние claude-процессы остаются осиротевшими (зомби Z, parent=1). Никто не делает wait() → зомби висят.
  3. 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 — «вернуть автономность» (критично)

  1. B-1 (F-1b): _write_task_file пишет в /repos/<repo>/.task напрямую через open(). Убрать docker-зависимость. (1-2ч)
  2. B-2 (F-2a): Popen stdout → файл напрямую (не PIPE). Убрать поток-читатель. (1-2ч)
  3. B-3: .task-*.md в .gitignore, проверка успешной записи. (30мин)
  4. S-5: Reviewer пишет verdict: во фронтматтер; QG читает только его. (1ч + правка reviewer.md)

→ После спринта 1: pipeline должен пройти ET-009-подобную задачу БЕЗ ручника.

Спринт 2 — «детерминизм и развязка»

  1. S-1b: QG-тесты гоняет сам оркестратор (make test), не зависим от Gitea CI. (2-3ч)
  2. S-4: git worktree per branch — изоляция задач. (3-4ч)
  3. S-2/S-3: деплой только через SSH-хук, rollback на деплой-стороне, не в shared-репо. Проверить/написать enduro-deploy-hook.sh. (2-3ч)
  4. M-3: единый stage-engine, убрать дубль _try_advance_stage. (2ч)

Спринт 3 — «надёжность»

  1. F-2b: очередь задач (SQLite-worker) вместо in-process daemon-потоков. (день)
  2. M-1, M-2: нормальный orphan-recovery + graceful timeout. (2-3ч)
  3. M-7: идемпотентность webhook'ов. (2ч)
  4. L-5: тесты на launcher. (полдня)

Резюме одной строкой

Корень всех бед — два бага: (1) нет docker-бинарника → запись таск-файлов падает молча; (2) Popen+PIPE+daemon-поток → зомби и потеря exit-кода. Оба чинятся за полдня и убирают 90% ручника. Остальное — развязка задач (worktree), детерминизм QG (свои тесты вместо Gitea CI) и машиночитаемые вердикты.