From 2c39b803f774a8cc4cdfd4a4197755ea6321b7bb Mon Sep 17 00:00:00 2001 From: Stream Date: Tue, 2 Jun 2026 19:50:02 +0300 Subject: [PATCH] auto-sync: 2026-06-02 19:50:01 --- tasks/multi-agent/AUDIT_2026-06-02.md | 199 ++++++++++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 tasks/multi-agent/AUDIT_2026-06-02.md diff --git a/tasks/multi-agent/AUDIT_2026-06-02.md b/tasks/multi-agent/AUDIT_2026-06-02.md new file mode 100644 index 0000000..4b12b10 --- /dev/null +++ b/tasks/multi-agent/AUDIT_2026-06-02.md @@ -0,0 +1,199 @@ +# Аудит мультиагентного оркестратора — 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` (только клиент): + ```dockerfile + 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//.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` и `_watchdog` — `daemon=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/` и判ает по 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 ` в одном `/repos/`. Если две задачи активны — checkout одной перетирает рабочую копию другой. + +**Последствие:** при параллельных задачах — хаос. На ET-009 это проявилось как «два коллектора» и путаница веток. + +**Фикс:** **git worktree per task/branch.** `git worktree add /repos/_wt/ ` — изолированная рабочая копия на задачу. Агент работает в своём 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//.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 — «детерминизм и развязка» +5. **S-1b:** QG-тесты гоняет сам оркестратор (`make test`), не зависим от Gitea CI. *(2-3ч)* +6. **S-4:** git worktree per branch — изоляция задач. *(3-4ч)* +7. **S-2/S-3:** деплой только через SSH-хук, rollback на деплой-стороне, не в shared-репо. Проверить/написать `enduro-deploy-hook.sh`. *(2-3ч)* +8. **M-3:** единый stage-engine, убрать дубль `_try_advance_stage`. *(2ч)* + +### Спринт 3 — «надёжность» +9. **F-2b:** очередь задач (SQLite-worker) вместо in-process daemon-потоков. *(день)* +10. **M-1, M-2:** нормальный orphan-recovery + graceful timeout. *(2-3ч)* +11. **M-7:** идемпотентность webhook'ов. *(2ч)* +12. **L-5:** тесты на launcher. *(полдня)* + +--- + +## Резюме одной строкой +**Корень всех бед — два бага: (1) нет docker-бинарника → запись таск-файлов падает молча; (2) Popen+PIPE+daemon-поток → зомби и потеря exit-кода. Оба чинятся за полдня и убирают 90% ручника. Остальное — развязка задач (worktree), детерминизм QG (свои тесты вместо Gitea CI) и машиночитаемые вердикты.**