auto-sync: 2026-06-02 19:50:01
This commit is contained in:
199
tasks/multi-agent/AUDIT_2026-06-02.md
Normal file
199
tasks/multi-agent/AUDIT_2026-06-02.md
Normal file
@@ -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/<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` и `_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/<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 — «детерминизм и развязка»
|
||||
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) и машиночитаемые вердикты.**
|
||||
Reference in New Issue
Block a user