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

200 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Аудит мультиагентного оркестратора — 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) и машиночитаемые вердикты.**