1256 lines
82 KiB
Plaintext
Executable File
1256 lines
82 KiB
Plaintext
Executable File
# Фаза 4 — исполнитель на Mac + `~/agents` + секция «Agents» в Eagle Dashboard
|
||
|
||
> План + статус. Старт 2026-09-26.
|
||
> Часть [[personal/projects/balda/balda-mm-delegation-plan]] (Фазы 4-5).
|
||
> Предыдущее: [[personal/projects/balda/balda-kraken-mm-deploy]] (Фаза 3 ✅ — бот отвечает Alex).
|
||
|
||
---
|
||
|
||
## 0. Статус шагов
|
||
|
||
| # | Шаг | Статус |
|
||
|---|---|---|
|
||
| 1 | `~/agents` + `~/cli-auth` + контейнер | ✅ собрано, запущено |
|
||
| 2 | Мост FastAPI :8790 | ✅ работает под launchd, туннель `agents-bridge.qentra.top` заведён Alex'ом |
|
||
| 3 | Сеть Kraken → Mac | ✅ **Cloudflare Tunnel** (постоянно); LAN остаётся для отладки |
|
||
| 4 | Секция «Agents» в Dashboard | ✅ активирована и проверена |
|
||
| 5 | Связка Балда → мост | ✅ конфиг `type: http`, образ 0.2.0, `tools/list` из docker-сети |
|
||
| 6 | Obsidian MCP для Балды | ✅ контейнер `obsidian-mcp` на Kraken + `type: sse` в конфиге (транспорт проверен) |
|
||
| 6a | Инструменты MCP в сессии Балды | ✅ **исправлено 2026-09-26** — Mattermost-классификатор добавлен в `ScopeResolver` (`app.go` + `preflight.go`), образ `balda-mm:0.1.11`, `session_memory.enabled: true`. См. §12 |
|
||
| 6b | Дубль MCP-ID → `duplicate tool`, Балда молчала | ✅ **исправлено 2026-09-26** — список ID только у провайдера, `balda.mcp_servers: []`; без пересборки образа. См. §13 |
|
||
| 6c | Structured-output память на `deepseek-chat` | ⚠️ open: `dead_letter / model_failure` — память не наполняется, ответы работают. См. §13 |
|
||
| 6d | Живая приёмка: Балда отвечает | ✅ **подтверждено 2026-09-26** через `mmctl post list` — бот отвечает, `duplicate tool` ушёл. См. §14 |
|
||
| 6e | 🔴 MCP-инструменты НЕ исполняются | ✅ **патч готов 2026-09-27** — форк `mallexxx/runtime` + PR [#4](https://github.com/normahq/runtime/pull/4), `replace` в `go.mod`, образ `balda-mm:0.1.12`. См. §15-16 |
|
||
| 6f | 🔴 `duplicate tool` — **КОРЕНЬ НАЙДЕН И ДОКАЗАН** (2026-09-27) | ✅ **корень: Balda регистрирует bundled-сервер ДВАЖДЫ на одном URL** (`balda` + `balda-session-memory-<token>`), `hostedToolsets` строит 2 тулсета с одинаковыми инструментами → `PackTool` падает. ⚠️ Прежняя версия («двойная паковка в ADK через `toolPreprocess`+`toolsetPreprocess`») **ОПРОВЕРГНУТА исполняемым тестом** — `mcptoolset.set` не реализует `ProcessRequest`. См. §19 |
|
||
| 6g | A/B-эксперимент откатом на `0.1.11` | ✅ **доведён 2026-09-27** — `0.1.11` падает С ТЕМ ЖЕ `duplicate tool`. Гипотеза §16.2 («форк обнажил») **ОПРОВЕРГНУТА**: баг был всегда, независимо от раунда. См. §16.4 |
|
||
| 6h | Фикс дублирующегося тулсета | 🟡 **КОД НАПИСАН 2026-09-27** — дедуп по эндпоинту в `hostedToolsets` (`runtime-local/agentfactory/agentfactory.go`) + 4 теста в `duplicate_toolset_test.go`. `go build`/`go vet` ✅, тесты **не прогнаны**. Остаётся: тесты → образ `balda-mm:0.1.13` → деплой → приёмка. См. §19.3-19.5 |
|
||
| 7 | Приёмка от аккаунта Alex | 🟡 **метод готов и проверен** — `mmctl --local token generate admin` даёт токен владельца без пароля; пост от `admin` проходит авторизацию (`settlement_stage: accepted`, лог подтверждает). Блокер — ход падает на `duplicate tool` (п. 6f/6h). См. §17 |
|
||
|
||
---
|
||
|
||
## 1. Решения
|
||
|
||
| # | Вопрос | Решение |
|
||
|---|---|---|
|
||
| 1 | Папка для проектов исполнителя | `~/agents` |
|
||
| 2 | Подтверждения | Full access → `permissions.mode: allow_all` |
|
||
| 3 | Управление линками в `~/agents` | Секция «Agents» в Eagle Dashboard |
|
||
| 4 | Auth CLI (claude/codex) | на хосте, `~/cli-auth`, **вне** `~/agents`; логинит **Alex вручную** |
|
||
| 5 | Сеть Kraken → Mac | 🟢 **Cloudflare Tunnel `agents-bridge.qentra.top`** — постоянный путь (Alex завёл маршрут); LAN `192.168.1.57:8790` — только для отладки |
|
||
| 6 | Провайдер исполнителя | `claude` (дефолт в `.env`); `codex` собирается в том же образе, переключается переменной |
|
||
| 7 | Тип MCP-транспорта в конфиге Балды | `http` (валидатор: `stdio \| http \| sse`); Obsidian — `sse` |
|
||
| 8 | Туннель для Obsidian MCP | ❌ **не нужен** — контейнер стоя́т на Kraken рядом с Балдой, ходят по docker-сети |
|
||
|
||
---
|
||
|
||
## 2. Ограничения macOS Docker (проверено тестами)
|
||
|
||
| Ограничение | Итог |
|
||
|---|---|
|
||
| Симлинк на внешний путь | битый в контейнере — цель резолвится от корня контейнера |
|
||
| Live `mount --bind` внутри VM | контейнер не видит (virtiofs строит маппинг на старте) |
|
||
| Сетевая шара SMB/NFS | в контейнер не протянуть (тот же virtiofs) |
|
||
| Hardlink на каталог | физически невозможен (ядро запрещает) |
|
||
| Hardlink через границу ФС | `EXDEV` |
|
||
|
||
**Следствие:** внешние проекты подключаются **только bind-mount'ом через `-v`** +
|
||
`docker-compose up -d` (2-3 сек). Hardlink — только для файлов внутри одного тома.
|
||
|
||
Детали механизма: [[personal/tech/macos-docker-virtiofs-mount-limits]].
|
||
|
||
---
|
||
|
||
## 3. Архитектура (собрано)
|
||
|
||
```
|
||
Mattermost (Alex)
|
||
│ «почини баг X в project-y»
|
||
▼
|
||
Балда-оркестратор (Kraken, DeepSeek, allow_all)
|
||
│ читает Obsidian, формирует ТЗ, /goalkeeper → work → validate
|
||
▼
|
||
MCP-сервер balda-executor-mcp (Kraken, контейнер в mattermost_mm-net) :8890
|
||
│ инструменты executor_run / executor_status / executor_health
|
||
│ HTTP + X-Bridge-Secret
|
||
▼
|
||
LAN: http://192.168.1.57:8790 ← Kraken и Mac в одной сети, туннель не нужен
|
||
▼
|
||
Мост (Mac, FastAPI :8790) — под launchd com.agents.bridge
|
||
│ idempotency key, таймаут 900 с, лимит 1 задача, shared secret
|
||
▼
|
||
Контейнер-исполнитель balda-executor (Mac, Docker)
|
||
│ -v ~/agents:/agents:rw
|
||
│ -v ~/Developer/<proj>:/agents/<name>:rw ← внешние проекты (override)
|
||
│ -v ~/cli-auth/claude:/root/.claude:rw ← auth на хосте
|
||
│ -v ~/cli-auth/codex:/root/.codex:rw ← auth на хосте
|
||
│ Node 24 + claude 2.1.283 + codex 0.157.1 + git
|
||
▼
|
||
diff + тесты + коммит → Балда → Mattermost
|
||
```
|
||
|
||
Детали MCP-сервера: [[personal/tech/balda-executor-mcp]].
|
||
|
||
### Auth CLI на хосте
|
||
|
||
| Данные | Хост | В контейнере | Права |
|
||
|---|---|---|---|
|
||
| Claude auth | `~/cli-auth/claude` | `/root/.claude` | 0700 |
|
||
| Codex auth | `~/cli-auth/codex` | `/root/.codex` | 0700 |
|
||
|
||
Пересборка образа и обновление CLI auth не сбрасывают. Токены вне `/agents` → агент их не видит.
|
||
|
||
---
|
||
|
||
## 4. Что собрано на диске
|
||
|
||
| Путь | Что |
|
||
|---|---|
|
||
| `~/agents/` | рабочая область исполнителя |
|
||
| `~/cli-auth/{claude,codex}` | 0700, монтируются в контейнер |
|
||
| `~/Docker/balda-executor/Dockerfile` | Node 24 + claude + codex + git + tini + CA |
|
||
| `~/Docker/balda-executor/docker-compose.yml` | 4 тома, сеть `executor-net`, build-секция |
|
||
| `~/Docker/balda-executor/.env` | `EXECUTOR_PROVIDER=claude`, 0600 |
|
||
| `~/Docker/balda-executor/agents-mounts.yaml` | манифест внешних проектов |
|
||
| `~/Docker/balda-executor/tools/render-mounts.py` | манифест → `compose.override.yaml` |
|
||
| `~/Developer/agents-bridge/` | мост: `app.py`, `store.py`, `executor.py`, `config.py` |
|
||
| `~/Library/LaunchAgents/com.agents.bridge.plist` | автозапуск + KeepAlive, `--host 0.0.0.0` |
|
||
| `~/Developer/eagle-dash/agents.py` | роутер секции «Agents» |
|
||
| `~/Developer/balda-executor-mcp/` | MCP-сервер: `server.py`, `Dockerfile`, `docker-compose.yml` |
|
||
| `/home/kraken/mcp/docker-compose.yml` (Kraken) | запуск контейнера MCP |
|
||
|
||
**Известные питфоллы (пройдены):**
|
||
- `docker compose` на Mac **отсутствует** — только `docker-compose` (v5.1.3);
|
||
**на Kraken наоборот**: `docker compose`, а `docker-compose` → `command not found`;
|
||
- `compose.override.yaml` обязателен при старте, иначе `up` падает на монтировании;
|
||
- образ исполнителя: `node:24-bookworm` вместо `scratch` (CA-сертификаты — иначе TLS-фейл);
|
||
- `psutil` не установлен в venv дашборда → supervisor не может трекать мост по PID; health для моста — `type: http`;
|
||
- `--host` берётся из `ProgramArguments` plist, **`.env` его не перебивает**;
|
||
смена аргументов требует `bootout`+`bootstrap`, не `kickstart`;
|
||
- образ Балды из `scratch`: **нет shell**, `docker exec … sh` не работает;
|
||
`docker.sock` в контейнер Балды **не смонтирован** → `docker run` изнутри невозможен;
|
||
- на Kraken в sshd `AllowTcpForwarding no` → обратный SSH-туннель поставить нельзя.
|
||
|
||
---
|
||
|
||
## 5. Секция «Agents» в Eagle Dashboard
|
||
|
||
Правки: `main.py` (include_router) · `agents.py` (новый роутер) · `templates/index.html` (вкладка `?tab=agents`) · `services.yaml` (сервис `agents_bridge`).
|
||
|
||
| Функция | Что делает |
|
||
|---|---|
|
||
| Список | содержимое `~/agents`: имя, размер, mtime, статус линка |
|
||
| Создать | `mkdir ~/agents/<name>` |
|
||
| Подключить внешний | запись в `agents-mounts.yaml` → `render-mounts.py` → `up -d` |
|
||
| Hardlink | `ln -F` для файлов, проверка `st_dev` |
|
||
| Отключить | убрать из манифеста + `up -d`; **файлы на диске целы** |
|
||
| Статус моста | `/api/agents/bridge` — provider, container, active, limit, pid, secret |
|
||
| Терминал | ссылка на вкладку `files` |
|
||
|
||
**API:** `GET/POST /api/agents` · `POST|DELETE /api/agents/{name}/mount` ·
|
||
`POST /api/agents/{name}/hardlink` · `GET /api/agents/{name}/usage` ·
|
||
`GET /api/agents/mounts` · `POST /api/agents/mounts/render` · `GET /api/agents/bridge`.
|
||
|
||
**Не делает:** не удаляет файлы, не убивает процессы, не создаёт symlink на внешние цели.
|
||
|
||
### Приёмка секции (2026-09-26) — 13 проверок, все прошли
|
||
|
||
| Проверка | Результат |
|
||
|---|---|
|
||
| создание агента | 201 ✅ |
|
||
| повторное создание | 409 ✅ |
|
||
| подключение внешнего проекта | 200 + контейнер Recreated ✅ |
|
||
| проект виден внутри контейнера | да, `ls /agents/testproj` → файлы ✅ |
|
||
| отвязка | 200; внутри контейнера пусто, на хосте файлы целы ✅ |
|
||
| hardlink на каталог | 400 (ядро запрещает) ✅ |
|
||
| hardlink на файл в том же томе | 200, `links=2`, одинаковый inode ✅ |
|
||
| слишком широкий `host_path` | 400 ✅ |
|
||
| размер каталога | OK ✅ |
|
||
| `?tab=agents` рендерится | 200, 75 КБ ✅ |
|
||
| JS-целостность шаблона | скобки парны, 8 методов на месте ✅ |
|
||
| Alpine в логе дашборда | ошибок нет ✅ |
|
||
|
||
---
|
||
|
||
## 6. Мост `agents-bridge`
|
||
|
||
`~/Developer/agents-bridge/`. Запуск под launchd: `com.agents.bridge`, лог `~/Library/Logs/agents-bridge.err.log`.
|
||
|
||
| Эндпоинт | Назначение |
|
||
|---|---|
|
||
| `GET /healthz` | живость моста + контейнера |
|
||
| `POST /task` | поставить задачу `{prompt, provider?}` |
|
||
| `GET /task/{id}` | статус + stdout/stderr/exit_code |
|
||
| `GET /tasks` | последние 20 задач |
|
||
|
||
Безопасность: заголовок `X-Bridge-Secret` (иначе 401), `Idempotency-Key` (повтор возвращает тот же `task_id`),
|
||
лимит 1 одновременная задача (иначе 429), таймаут 900 с, обрезка вывода 200 КБ.
|
||
|
||
### Приёмка моста (2026-09-26) — 6 проверок, все прошли
|
||
|
||
| Проверка | Результат |
|
||
|---|---|
|
||
| без секрета / неверный секрет | 401 / 401 ✅ |
|
||
| два POST с одним `Idempotency-Key` | одинаковый `task_id` ✅ |
|
||
| задача дошла до CLI в контейнере | да ✅ |
|
||
| ответ CLI вернулся (`Not logged in · Please run /login`) | да — это ответ Claude Code, конвейер рабочий ✅ |
|
||
| состояние в SQLite (WAL) | ✅ |
|
||
| `/healthz` видит контейнер | `status: ok` ✅ |
|
||
|
||
**Блокер приёмки:** CLI внутри контейнера не авторизован. Логинит **Alex вручную**:
|
||
`claude` / `codex` внутри контейнера, credentials кладутся в `~/cli-auth/{claude,codex}`.
|
||
|
||
---
|
||
|
||
## 7. Сеть Kraken → Mac — 🟢 Cloudflare Tunnel (постоянно), LAN — отладка
|
||
|
||
### Итог: постоянный путь — туннель, не IP
|
||
|
||
🔴 **Alex потребовал убрать LAN-IP из постоянного конфига** — домашний IP не является
|
||
стабильным адресом: сменится адрес Mac или уедет роутер, и MCP отвалится молча.
|
||
Туннель даёт стабильное имя и не привязан к топологии сети.
|
||
|
||
```env
|
||
BRIDGE_URL=https://agents-bridge.qentra.top
|
||
```
|
||
|
||
Маршрут `agents-bridge.qentra.top` → `http://localhost:8790` заведён Alex'ом в
|
||
Cloudflare Zero Trust. Проверено из docker-сети Kraken:
|
||
|
||
| Проверка | Результат |
|
||
|---|---|
|
||
| `curl https://agents-bridge.qentra.top/healthz` из `mattermost` | HTTP 200, `{"status":"ok","container":"balda-executor",…}` ✅ |
|
||
| заголовки ответа | `server: cloudflare`, `cf-ray: …-FRU`, `HTTP/2 200` ✅ |
|
||
| `ping` Kraken → `192.168.1.57` (LAN, резерв) | 2/2, RTT 2.4 мс ✅ |
|
||
|
||
⚠️ **На момент записи Access Application на `agents-bridge` не задан** — туннель
|
||
отвечает 200 без авторизации. Пока защита — `X-Bridge-Secret` и
|
||
непредсказуемое имя поддомена. **Завести Access Application** (или подтвердить, что
|
||
она есть), иначе мост фактически открыт в интернет.
|
||
|
||
### Что НЕ сработало и почему (чтобы не повторять)
|
||
|
||
| Путь | Почему отпал |
|
||
|---|---|
|
||
| Обратный SSH-туннель (`ssh -R`) | 🔴 **На Kraken в sshd стоит `AllowTcpForwarding no`.** Симптом: `Error: remote port forwarding failed for listen port 18790`, в verbose — `debug1: Remote: Server has disabled port forwarding.` Не диагностировать как «порт занят»: порт свободен, форвард выключен администратором |
|
||
| LAN как постоянный адрес | Alex против: IP не стабилен. Годится только для отладки |
|
||
|
||
📌 Если понадобится туннель в будущем — сначала проверить `AllowTcpForwarding` на Kraken,
|
||
потом уже ставить форвард. Cloudflare Tunnel этот запрет обходит (исходящее соединение,
|
||
не SSH-форвард).
|
||
|
||
### Мост теперь слушает всю сеть — это осознанное изменение
|
||
|
||
| Было | Стало | Почему |
|
||
|---|---|---|
|
||
| `BRIDGE_HOST=127.0.0.1` | `BRIDGE_HOST=0.0.0.0` | иначе Kraken не достучится |
|
||
|
||
⚠️ **Порт 8790 теперь открыт в локальной сети.** Защита — `X-Bridge-Secret` (обязателен,
|
||
иначе `POST /task` отдаёт 503). При уходе Mac в недоверенную сеть — вернуть `127.0.0.1`
|
||
и поднимать туннель.
|
||
|
||
🔴 **Питфолл: `.env` не перебивает plist.** Я поменял `BRIDGE_HOST` в `.env`, но мост
|
||
продолжал слушать `127.0.0.1` — потому что `--host` **жёстко задан в `ProgramArguments`**
|
||
plist. Симптом: `lsof` показывает `127.0.0.1:8790` после «перезапуска с новым конфигом».
|
||
Исправлять надо **в plist**, а не в `.env`.
|
||
|
||
```bash
|
||
# применить: правка plist → перезагрузка сервиса (не kickstart!)
|
||
launchctl bootout gui/$(id -u)/com.agents.bridge
|
||
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.agents.bridge.plist
|
||
# проверка: должно быть *:8790, а не 127.0.0.1:8790
|
||
lsof -nP -iTCP:8790 -sTCP:LISTEN
|
||
```
|
||
|
||
`kickstart` перечитывает только программу, но **не** изменённый `ProgramArguments` —
|
||
для смены аргументов нужен `bootout` + `bootstrap`.
|
||
|
||
---
|
||
|
||
## 8. Риски
|
||
|
||
| Риск | Оценка | Митигация |
|
||
|---|---|---|
|
||
| Мост опубликован в интернет | высокий | Access Application + shared secret |
|
||
| Полный доступ агента к `~/agents` | средний | бэкапы; `git rm`-семантика вместо `rm` |
|
||
| Бан подписки за «unusual traffic» | средний | 1 параллельная задача, официальный бинарь, без прокси токена |
|
||
| Hardlink через ФС → `EXDEV` | высокий | проверка `st_dev` в API |
|
||
| Mac спит → туннель и мост недоступны | средний | задачи в durable-очереди Балды, идемпотентность |
|
||
|
||
---
|
||
|
||
## 9. Открыто
|
||
|
||
1. ✅ **Конфиг Балды переведён на HTTP-MCP** — `type: http` →
|
||
`http://balda-executor-mcp:8890/mcp`. Плюс образ на Kraken обновлён до `0.2.0`
|
||
(в `0.1.0` нет `uvicorn` → рестарт-петля). См. [[personal/tech/balda-executor-mcp]] §10e-10f.
|
||
2. ✅ **Obsidian прописан в конфиг Балды** — контейнер `obsidian-mcp` на Kraken поднят,
|
||
`runtime.mcp_servers.obsidian` добавлен. ⚠️ Тип — **`sse`**, не `http`:
|
||
```yaml
|
||
runtime:
|
||
mcp_servers:
|
||
executor-mcp:
|
||
type: http
|
||
url: "http://balda-executor-mcp:8890/mcp"
|
||
obsidian:
|
||
type: sse
|
||
url: "http://obsidian-mcp:3101/sse"
|
||
balda:
|
||
mcp_servers:
|
||
- executor-mcp
|
||
- obsidian
|
||
```
|
||
См. §11 ниже.
|
||
3. ✅ **MCP-инструменты не видны в сессии Балды — ИСПРАВЛЕНО (2026-09-26).** Полный разбор в §12.
|
||
Кратко: причина была **не** в снапшоте каталога (он в порядке) и не только в провайдерском
|
||
`mcp_servers`. Настоящий выключатель — `balda.session_memory.enabled`, а он на Mattermost
|
||
падал из-за отсутствующего классификатора scope. Классификатор добавлен — см. §12.
|
||
⚠️ Тест-пост должен идти **от админского токена** — посты от имени самого бота Балда
|
||
игнорирует (правильно), в логе `dropping posted event: bot not mentioned`:
|
||
`docker exec mattermost mmctl --local token generate admin …`.
|
||
И **не забывать `@balda`** в тексте поста.
|
||
🔴 **Продолжение (§13):** после фикса классификатора всплыл второй дефект — дубль MCP-ID
|
||
в двух секциях → `duplicate tool` → ход падал, Балда молчала. Список ID оставлен **только
|
||
у провайдера**. И третий, неблокирующий: память на `deepseek-chat` не наполняется
|
||
(`dead_letter / model_failure`).
|
||
🔴 **Продолжение 2 (§§15-16, 2026-09-27):** инструменты всё равно не исполнялись — в
|
||
`runtime v2` вырезан tool_calls. Форк собран и задеплоен (`0.1.12`), и **обнажил** двойную
|
||
паковку инструментов (`duplicate tool` вернулся). Разбор — §16.
|
||
4. 🟡 Логин CLI (Alex вручную) — метод приёмки в обход (admin PAT, §17) есть, но сквозная
|
||
приёмка упирается в `duplicate tool` (§16).
|
||
5. 🔴 **Завести Access Application** на `agents-bridge.qentra.top` — сейчас туннель
|
||
отвечает 200 без авторизации (проверено). Защита — только `X-Bridge-Secret`.
|
||
6. ⚠️ **`~/Developer/balda-executor-mcp/docker-compose.yml` в репо → `0.2.0`**;
|
||
на Kraken compose лежит в `/home/kraken/balda-executor-mcp/docker-compose.yml`
|
||
(был `/home/kraken/mcp/` — путь изменился, старый compose мёртв).
|
||
|
||
## 9a. 🔴 Obsidian на Kraken — существовал ДО этой сессии
|
||
|
||
Важное исправление ошибочного вывода. Я дважды сказал Alex, что Obsidian MCP
|
||
на Kraken нет / есть только на Mac. **Оба утверждения неверны.**
|
||
|
||
**Почему ошибся:** искал через `docker ps --filter name=obsidian` — контейнера нет.
|
||
Но на Kraken Obsidian MCP запущен **не контейнером**, а как stdio-процесс внутри
|
||
агента Hermes:
|
||
|
||
```
|
||
mcpvault /vault (PID 1168741, UID 10000)
|
||
└─ родитель PID 5039 = hermes gateway (контейнер hermes-kraken)
|
||
```
|
||
|
||
Конфиг — `/opt/data/config.yaml` **внутри** `hermes-kraken`. Волт на Kraken реальный:
|
||
`/home/kraken/obsidian` → `/srv/…-49e8f586…/obsidian` (с `personal/`, `family/`, `.git/`).
|
||
|
||
📌 **Правило поиска: `docker ps` не является доказательством отсутствия сервиса.**
|
||
Сначала `ps aux | grep <name>`, потом порты (`ss -tlnp`), и только потом контейнеры.
|
||
|
||
## 10. Шаг 5 — связка Балда → мост (обновлено 2026-09-26)
|
||
|
||
| Компонент | Где | Статус |
|
||
|---|---|---|
|
||
| MCP-сервер `balda-executor-mcp` | `~/Developer/balda-executor-mcp/` | ✅ код + образ `0.2.0` |
|
||
| Образ на Kraken | `balda-executor-mcp:0.2.0` (0.1.0 оставлен для отката) | ✅ загружен, хеш сверен |
|
||
| Контейнер MCP | Kraken, `/home/kraken/balda-executor-mcp/docker-compose.yml` | ✅ Up, `tools/list` = 3 инструмента |
|
||
| Конфиг Балды | `runtime.mcp_servers.{executor-mcp: http, obsidian: sse}` | ✅ залит |
|
||
| `.env` Балды | `BRIDGE_URL=https://agents-bridge.qentra.top` + `BRIDGE_SECRET` | ✅ туннель, не IP |
|
||
|
||
Бэкапы на Kraken: `config.yaml.bak-mcp-20260926-194313`, `config.yaml.bak-obsidian-20260926-214849`,
|
||
`.env.bak-mcp-*`, `.env.bak-url-*`.
|
||
|
||
Проверено живьём: `/healthz` = `{"status":"ok",…}`, `tools/list` из docker-сети отдаёт
|
||
`executor_run` / `executor_status` / `executor_health`, туннель до моста отвечает 200 через Cloudflare.
|
||
|
||
📌 **Ключевой вывод по архитектуре:** MCP-сервер обязан быть **отдельным контейнером
|
||
с HTTP-транспортом**. Вариант «stdio + `docker run` изнутри Балды» нерабочий:
|
||
в образе Балды нет shell и не смонтирован `docker.sock`.
|
||
|
||
## 11. Obsidian MCP на Kraken — контейнер (сделан в этой сессии)
|
||
|
||
Балде нужен Obsidian **не по туннелю**: контейнер стоит рядом, в той же docker-сети.
|
||
Туннель к Obsidian Alex заводить не надо — это была моя ошибка в предложении.
|
||
|
||
| Что | Значение |
|
||
|---|---|
|
||
| Проект | `~/Developer/obsidian-mcp-kraken/` (Dockerfile) |
|
||
| Образ | `obsidian-mcp-image:0.1.0` (node + `mcp-proxy` + `mcpvault`) |
|
||
| Контейнер на Kraken | `obsidian-mcp`, сеть `mattermost_mm-net` |
|
||
| Внутренний порт | `3101` (`--port 3101 -- mcpvault /vault`), **наружу не публикуется** |
|
||
| Том | `/srv/dev-disk-by-uuid-49e8f586-…/obsidian:/vault:ro` (только чтение) |
|
||
| Endpoint для Балды | `http://obsidian-mcp:3101/sse`, `type: sse` |
|
||
|
||
⚠️ **Порт именно 3101, не 8080.** `mcp-proxy` слушает порт из `--port`; проба на 8080
|
||
даёт `Connection refused` при живом контейнере. Проверять так:
|
||
|
||
```bash
|
||
# из контейнера в mattermost_mm-net (там есть curl)
|
||
docker exec mattermost sh -c 'curl -sS -m 8 -H "Accept: text/event-stream" http://obsidian-mcp:3101/sse'
|
||
# ожидаем: event: endpoint / data: /messages?sessionId=…
|
||
```
|
||
|
||
📌 **`mcpvault` — только stdio.** HTTP-режима у него нет, поэтому и нужен обёртка
|
||
`mcp-proxy` (`ENTRYPOINT mcp-proxy`, `CMD --port 3101 -- mcpvault /vault`).
|
||
Проверено на живом контейнере: `mcp-proxy --port 3101 -- mcpvault /vault`.
|
||
|
||
📌 У `mcp-proxy` **нет флага `--host`** — в отличие от Python-моста. Передавать только
|
||
`--port` + `--`. Лишний `--host` роняет контейнер.
|
||
|
||
## 12. ✅ MCP-инструменты в сессии Балды — корень и фикс (2026-09-26)
|
||
|
||
**Симптом:** `/reset` перечисляет `MCP: balda, executor-mcp, obsidian`, но Балда отвечает
|
||
«namespace `obsidian` не подключён к сессии» и **не вызывает инструменты**
|
||
(`function_call_part_count: 0`).
|
||
|
||
### Три ложных следа (пройдены, чтобы не повторять)
|
||
|
||
| След | Почему ложный |
|
||
|---|---|
|
||
| Снапшот каталога `appruntime_catalog_snapshot` пуст | **Не пуст.** Самый свежий (`scope: effective`) содержит `builtin` + `configured-mcp: executor-mcp` + `configured-mcp: obsidian`, транспорты `streamable-http` / `sse` — всё верно. Я предлагал чистить `state.db` — **правка не нужна**, данные целы |
|
||
| Провайдер `type: openai` не умеет MCP | Умеет. Ходит через agent runtime, ограничения по типу провайдера нет |
|
||
| `balda.mcp_servers` достаточно | **Недостаточно, но и не корень.** Это лишь список ID для сессии |
|
||
|
||
### Настоящий корень — цепочка из двух звеньев
|
||
|
||
**Звено 1: серверы подключаются через секцию ПРОВАЙДЕРА.**
|
||
Доки Balda: MCP-серверы описываются в `runtime.mcp_servers` и **подключаются провайдером**
|
||
через `runtime.providers.<id>.mcp_servers`. Формула:
|
||
```
|
||
effective = bundled(balda) + runtime.providers.<provider>.mcp_servers + balda.mcp_servers
|
||
```
|
||
Без провайдерской секции `balda.mcp_servers` игнорируется.
|
||
|
||
```yaml
|
||
runtime:
|
||
providers:
|
||
deepseek:
|
||
type: openai
|
||
openai: {…}
|
||
mcp_servers: # 🔴 обязательная секция
|
||
- executor-mcp
|
||
- obsidian
|
||
```
|
||
|
||
**Звено 2 (главное): 🔴 `balda.session_memory.enabled` — выключатель MCP-биндера сессии.**
|
||
Биндер собирается в composition root:
|
||
```go
|
||
// internal/apps/balda/app.go:275-278
|
||
fx.Annotate(
|
||
func() bool { return cfg.Balda.SessionMemory.Enabled },
|
||
fx.ResultTags(`name:"balda_session_memory_enabled"`),
|
||
),
|
||
// internal/apps/balda/sessionapp/module.go:191-196
|
||
fx.Annotate(
|
||
func(broker *sessionmemorymcp.ContextBroker, enabled bool) baldaagent.SessionMCPBinder {
|
||
return SessionMCPBinderAdapter{Broker: broker, Enabled: enabled}
|
||
},
|
||
fx.ParamTags(``, `name:"balda_session_memory_enabled"`),
|
||
),
|
||
```
|
||
⚠️ **`balda.memory` и `balda.session_memory` — РАЗНЫЕ секции.** Первая включает память,
|
||
вторая — MCP-биндер сессии. В конфиге была только `memory`.
|
||
|
||
При включении `session_memory` сессия на Mattermost падала:
|
||
```
|
||
warn … "observe reset boundary: no session-memory classifier is registered for \"mattermost\""
|
||
→ в чате: "Could not reset this session."
|
||
```
|
||
|
||
### Фикс — классификатор Mattermost в `ScopeResolver`
|
||
|
||
**Ключевое:** классификатор для Mattermost **уже был написан** (в составе MM-коннектора,
|
||
`channel/mattermost/locator_codec.go:141-156` — `dm`→personal, `channel`→group).
|
||
Не хватало ровно **регистрации** в двух картах резолвера.
|
||
|
||
| Файл | Строки | Правка |
|
||
|---|---|---|
|
||
| `internal/apps/balda/app.go` | 28 | импорт → алиас `baldamattermost` |
|
||
| `internal/apps/balda/app.go` | 284 | `baldamattermost.ChannelType: baldamattermost.ClassifyLocatorScope,` |
|
||
| `internal/apps/balda/app.go` | 991 | `mattermost.ValidateConfig` → `baldamattermost.ValidateConfig` (следствие алиаса) |
|
||
| `internal/apps/balda/preflight.go` | 13 | импорт алиаса |
|
||
| `internal/apps/balda/preflight.go` | 169 | строка регистрации в карте |
|
||
|
||
🔴 **Карт резолвера ДВЕ** — `app.go` (рантайм) и `preflight.go` (валидация).
|
||
Править **обе**: если поправить одну, preflight разойдётся с рантаймом.
|
||
|
||
```go
|
||
// app.go:280-285 и preflight.go:165-170 — одинаковый список
|
||
sessionmemoryapp.NewScopeResolver(map[string]sessionmemoryapp.ScopeClassifier{
|
||
baldatelegram.ChannelType: baldatelegram.ClassifyLocatorScope,
|
||
baldaslackagent.ChannelType: baldaslackagent.ClassifyLocatorScope,
|
||
baldazulip.ChannelType: baldazulip.ClassifyLocatorScope,
|
||
baldamattermost.ChannelType: baldamattermost.ClassifyLocatorScope,
|
||
})
|
||
```
|
||
|
||
📌 **Урок:** при добавлении нового транспорта в Балду классификатор scope надо
|
||
**не только написать, но и зарегистрировать в двух картах**. Симптом отсутствия
|
||
регистрации — `no session-memory classifier is registered for "<channel>"`,
|
||
проявляется **только** при `session_memory.enabled: true`.
|
||
|
||
### Проверка (гейты)
|
||
|
||
```bash
|
||
cd ~/Developer/balda
|
||
go build ./... # ✅
|
||
go tool go-arch-lint check --project-path . # ✅ OK
|
||
gofmt -l internal/apps/balda/app.go internal/apps/balda/preflight.go # ✅ пусто
|
||
go test -race ./internal/apps/balda/sessionmemoryapp/... \
|
||
./internal/apps/balda/channel/mattermost/... # ✅ ok
|
||
```
|
||
|
||
⚠️ **Полный `go test -race ./...` = 7 предсуществующих падений** (не регрессия от правок):
|
||
`cmd/balda` (1), `internal/apps/balda` (2), `mcpruntime` (1), `session` (2), `sessionmemory`
|
||
layout-тесты. Проверено `git stash` — **те же падения на чистом дереве**.
|
||
📌 Метод: прежде чем объявлять падения регрессией, снять правки `git stash` и прогнать те же
|
||
пакеты на базовой ревизии.
|
||
|
||
### Деплой
|
||
|
||
```bash
|
||
# сборка (Mac, arm64 builder) — Dockerfile.release, НЕ Dockerfile
|
||
docker buildx build --builder arm64builder --platform linux/arm64 \
|
||
-f Dockerfile.release --provenance=false --load -t balda:mm-classifier .
|
||
docker save balda:mm-classifier -o /tmp/balda-mm-classifier.tar # 20 МБ
|
||
scp /tmp/balda-mm-classifier.tar kraken:/tmp/
|
||
ssh kraken 'docker load -i /tmp/balda-mm-classifier.tar && docker tag balda:mm-classifier balda-mm:0.1.11'
|
||
```
|
||
|
||
На Kraken: `docker-compose.yml` → `image: balda-mm:0.1.11`; `config/config.yaml` →
|
||
`session_memory: enabled: true`; оба файла править **локально**, заливать `scp`
|
||
(не sed/heredoc). Бэкапы: `config.yaml.bak-mm-classifier-<ts>`,
|
||
`docker-compose.yml.bak-mm-classifier-<ts>`.
|
||
|
||
```bash
|
||
ssh kraken 'cd /home/kraken/balda && docker compose up -d --force-recreate'
|
||
```
|
||
|
||
✅ **Логи подтверждают фикс:**
|
||
```
|
||
SessionMCPBinder … provided
|
||
session-memory runtime … started
|
||
session memory … started
|
||
session manager ready · balda_provider=deepseek
|
||
bundled MCP listener started · 127.0.0.1:33703 · routes /mcp/balda
|
||
Balda bot started. Press Ctrl+C to stop.
|
||
```
|
||
⚠️ Побочное: `failed to load bot identity for startup log: getMe response missing result` —
|
||
**не дефект**, только косметика лога идентичности бота.
|
||
|
||
### Что проверяет Alex (живая приёмка)
|
||
|
||
```
|
||
@balda /reset
|
||
→ сессия сбрасывается БЕЗ «Could not reset this session»
|
||
→ в стартовом сообщении MCP: balda, executor-mcp, obsidian
|
||
|
||
@balda какие mcp серверы тебе доступны?
|
||
→ перечисляет obsidian и executor-mcp как ИНСТРУМЕНТЫ
|
||
```
|
||
|
||
---
|
||
|
||
## 13. 🔴 Второй дефект того же включения: `duplicate tool` (2026-09-26, та же сессия)
|
||
|
||
**Симптом после деплоя 0.1.11:** `/reset` больше **не падает** (классификатор работает — звено 2
|
||
исправлено), но Балда **молчит** на обычное сообщение. В логе на каждый пост:
|
||
|
||
```
|
||
error … "agent run: duplicate tool: \"balda.control.shutdown\""
|
||
warn … command failed with retryable error (delivery_attempt 1..5)
|
||
warn … command deadlettered (terminal:unclassified)
|
||
```
|
||
|
||
**Причина:** ID MCP-серверов были перечислены **дважды** — в `balda.mcp_servers` **и** в
|
||
`runtime.providers.deepseek.mcp_servers`. Эффективный набор = **объединение**, поэтому
|
||
bundled-`balda` подключался второй раз, и его инструмент `balda.control.shutdown`
|
||
регистрировался повторно.
|
||
|
||
**Где падает:** не в Балде, а в **`google.golang.org/adk`**:
|
||
```go
|
||
// $(go env GOMODCACHE)/google.golang.org/adk/v2@v2.4.0/tool/toolutils/toolutils.go:47-48
|
||
if _, ok := req.Tools[name]; ok {
|
||
return fmt.Errorf("duplicate tool: %q", name)
|
||
}
|
||
```
|
||
📌 `grep -rn "duplicate tool"` по репозиторию Balda **пуст** — искать надо в ADK. Дедупликация
|
||
в `session/manager.go:251-270` чистит **ID серверов**, но не повторную регистрацию **инструментов**.
|
||
|
||
**Фикс — конфиг, без пересборки образа:** список ID держим **только у провайдера**.
|
||
```yaml
|
||
balda:
|
||
mcp_servers: [] # 🔴 пусто — иначе дубль
|
||
runtime:
|
||
providers:
|
||
deepseek:
|
||
mcp_servers:
|
||
- executor-mcp
|
||
- obsidian
|
||
```
|
||
Бэкап: `config.yaml.bak-nodedup-<ts>`. Применено `docker compose up -d --force-recreate`.
|
||
|
||
⚠️ **Третий (не блокирующий) дефект того же включения:** `session_memory.enabled: true`
|
||
активирует structured-output derivation памяти, и `deepseek-chat` не отдаёт нужную схему:
|
||
```
|
||
warn … operation=dead_letter kind=boundary.v1 error_code=model_failure
|
||
error: output does not match schema: (root): output is required
|
||
```
|
||
→ `session-memory capability retry limit exhausted`, 7 попыток. Ответы работают,
|
||
**память не наполняется**. Отдельная задача: либо модель со structured output, либо
|
||
политика derivation без structured-выхода.
|
||
|
||
### Статус после §13
|
||
|
||
| Проверка | Результат |
|
||
|---|---|
|
||
| `SessionMCPBinder` в DI-графе | ✅ `provided` |
|
||
| старт без ошибок классификатора | ✅ `session memory … started` |
|
||
| `duplicate tool` после фикса конфига | ✅ в логах чисто (после recreate) |
|
||
| живой ответ Балды на `@balda …` | ✅ **подтверждён 2026-09-26** — см. §14 |
|
||
|
||
---
|
||
|
||
## 14. ✅ Живая приёмка без Alex: чтение ленты канала (2026-09-26)
|
||
|
||
**Проблема метода:** Alex справедливо отказался быть «руками» для проверки
|
||
(«ты ведь даже не поверил ожил ли балда вообще»). Нужен способ проверить ответ бота
|
||
**самому**, не прося пользователя писать в чат.
|
||
|
||
### Что НЕ работает
|
||
|
||
| Путь | Ошибка | Почему |
|
||
|---|---|---|
|
||
| `mmctl post create qentra:balda-test --message '…'` | `creating posts is not supported in local mode` | `--local` общается через unix-сокет: чтение разрешено, **запись от имени человека — нет** |
|
||
| `mmctl post create --channel <id>` | `unknown flag: --channel` | формат цели — `team:channel` позиционным аргументом: `mmctl post create qentra:balda-test` |
|
||
| `docker exec mattermost curl -X POST /api/v4/users/login` | `Syntax error: Unterminated quoted string` | вложенные кавычки JSON ломаются в ssh+sh; нужен **файл-скрипт**, не инлайн |
|
||
|
||
### Что работает — `post list` по ленте
|
||
|
||
```bash
|
||
# имена: team → channel
|
||
docker exec mattermost mmctl --local team list # → qentra
|
||
docker exec mattermost mmctl --local channel list qentra # → balda-test, off-topic, town-square
|
||
|
||
# чтение ленты (это и есть доказательство «бот отвечает»)
|
||
docker exec mattermost mmctl --local post list qentra:balda-test 2>&1 | tail -25
|
||
```
|
||
|
||
Фактический вывод после фикса (§12 + §13) — **Балда отвечает**:
|
||
|
||
```
|
||
[admin] @balda /reset
|
||
[balda] 🚀 Session Started • Name: balda • ID: mm-c-7aafea13 •
|
||
Model: deepseek-chat • Reasoning: none • Type: openai •
|
||
MCP: balda, executor-mcp, obsidian
|
||
[admin] @balda какие mcp видишь
|
||
[balda] MCP-доступ в этой сессии: только balda.memory.
|
||
Недоступно: balda.workspace (git-тулинг) и субагентские MCP-инструменты
|
||
```
|
||
|
||
📌 **Метод приёмки без GUI и без участия заказчика:** `mmctl --local post list <team>:<channel>`.
|
||
Пост в треде и пост в канале видны одинаково; автор виден по `user_id`.
|
||
⚠️ Отправить пост **от имени человека** через `--local` нельзя — для генерации
|
||
человеческого поста нужен токен админа через REST (не `--local`).
|
||
|
||
### Серверы исправны — проверено из docker-сети
|
||
|
||
```bash
|
||
# executor-mcp: initialize → сессия → tools/list
|
||
docker exec mattermost curl -s -X POST "http://balda-executor-mcp:8890/mcp" \
|
||
-H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
|
||
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"probe","version":"1.0"}}}'
|
||
# → 200, mcp-session-id, capabilities:{tools:{}}
|
||
```
|
||
|
||
`tools/list` отдаёт **3 инструмента** с валидной схемой:
|
||
`executor_run`, `executor_status`, `executor_health`.
|
||
|
||
```bash
|
||
# obsidian-mcp живой
|
||
docker exec mattermost curl -s -m 5 -i "http://obsidian-mcp:3101/sse"
|
||
# → 200, Content-Type: text/event-stream, event: endpoint / data: /messages?sessionId=…
|
||
```
|
||
|
||
### 🔴 Остаток: серверы перечислены, инструменты не исполняются
|
||
|
||
Сессия показывает `MCP: balda, executor-mcp, obsidian`, серверы исправны, но Балда
|
||
на вопрос отвечает «доступно только `balda.memory`» — **и то же про свой `balda.workspace`**.
|
||
То есть **ни один** MCP-инструмент не работает, включая встроенный.
|
||
|
||
**Причина — тип провайдера.** `internal/apps/balda/agent/builder.go`:
|
||
|
||
```go
|
||
// :518 — bundled `balda` добавляется КОДОМ, не конфигом
|
||
MCPServers: mergeMCPServerIDsWithBase([]string{"balda"}, agentCfg.MCPServers, nil),
|
||
|
||
// :479-500 — GetAgentMetadata разбирает ДВА класса провайдеров
|
||
switch strings.TrimSpace(agentCfg.Type) {
|
||
case agentconfig.AgentTypeCodexACP: acp = agentCfg.CodexACP
|
||
case agentconfig.AgentTypeClaudeCodeACP: acp = agentCfg.ClaudeCodeACP
|
||
case agentconfig.AgentTypeGeminiACP: acp = agentCfg.GeminiACP
|
||
// … всего 10 ACP-типов
|
||
}
|
||
// :508 — openai попадает сюда: только модель, без ACP-конфига
|
||
case agentCfg.OpenAI != nil:
|
||
model = strings.TrimSpace(agentCfg.OpenAI.Model)
|
||
```
|
||
|
||
`type: openai` (наш `deepseek`) получает **список ID** серверов — поэтому `MCP:` в стартовом
|
||
сообщении их перечисляет — но **не получает ACP-рантайм**, который исполняет инструменты.
|
||
Инструменты требуют провайдера **ACP-типа**: `claude_code_acp`, `codex_acp`, `gemini_acp`,
|
||
`opencode_acp`, `copilot_acp`, `grok_acp`, `registry_acp`, `agy_acp`, `antigravity_acp`, `generic_acp`.
|
||
|
||
⚠️ **Осторожно с формулировкой:** ранее в §12 «провайдер `type: openai` не умеет MCP» было
|
||
записано как **ложный след** — и это верно в части *подключения* (ID попадают в сессию через
|
||
секцию провайдера, ограничения по типу на этом шаге нет). Но **исполнение** инструментов
|
||
действительно требует ACP-провайдера. Оба утверждения верны на своих слоях:
|
||
подключение ≠ исполнение.
|
||
|
||
📌 **Проверка лимитов (не они):** `mcpruntime/reconciler.go:97` — defaults
|
||
`MaxServers: 32, MaxTools: 256, MaxToolMetadataBytes: 256<<10`. Три сервера и три
|
||
инструмента в них укладываются; гипотеза «лимит отбрасывает серверы» **опровергнута**.
|
||
|
||
**Открытое решение (за Alex'ом):** перевести `balda.provider` на ACP-тип
|
||
(`claude_code_acp` / `codex_acp` / `gemini_acp`) — тогда MCP-инструменты заработают.
|
||
|
||
---
|
||
|
||
## 15. 🔴 Корень найден: в `runtime v2` вырезан tool_calls (2026-09-26)
|
||
|
||
**Поворот в диагностике.** Alex спросил: «Это не та же проблема, которую мы решали в
|
||
mallexxx-форке старой балды?» — **да, ровно та же.**
|
||
|
||
### Что было в старом форке (закрыто Alex'ом 2026-06-22)
|
||
|
||
`~/Developer/norma-local` → `pkg/runtime/hostedagent/openai.go`, коммит `91c2c0a`
|
||
(+286 строк кода, +312 строк тестов). Патч делал 4 вещи:
|
||
|
||
| Блок | Что добавлял |
|
||
|---|---|
|
||
| Структуры | `openAIToolDefinition`, `openAIFunction`, `openAIToolCall` |
|
||
| Запрос | `Tools []openAIToolDefinition` + `openAIToolsFromConfig()` (genai.Tool[] → OpenAI), фильтр имён `^[a-zA-Z0-9_-]+$` |
|
||
| Ответ | `parseChatResponse()` — сборка `FunctionCall` из `tool_calls`, **разбор двойной сериализации аргументов DeepSeek** |
|
||
| История | `contentToOpenAI()` — конвертация `FunctionCall`/`FunctionResponse`, ключ `output` (специфика Obsidian MCP) |
|
||
| Цикл | `TurnComplete = !hasToolCalls` — ADK делает второй раунд для исполнения tool |
|
||
|
||
Плюс фикс двойной сериализации: DeepSeek отдаёт `function.arguments` как **JSON-строку**,
|
||
поэтому сначала `json.Unmarshal` в `string`, затем эту строку — в объект.
|
||
|
||
### Что в `runtime v2.0.11` сейчас — вырезано НОЛЬ из четырёх
|
||
|
||
`~/go/pkg/mod/github.com/normahq/runtime/v2@v2.0.11/hostedagent/openai.go` — **246 строк**:
|
||
|
||
| Что | Статус в v2 |
|
||
|---|---|
|
||
| `Tools` в запросе | ❌ `openAIToolsFromConfig` отсутствует целиком |
|
||
| `tool_calls` в ответе | ❌ `parseChatResponse` знает только `Content` |
|
||
| История tool-результатов | ❌ `openAIMessagesFromRequest` режет всё, кроме текста (:204-208) |
|
||
| `TurnComplete` | ❌ жёстко `true` (:107) |
|
||
|
||
🔴 **Отдельный регресс v2:** `parseChatResponse` (:180-182) падает, если `Content` пуст:
|
||
```go
|
||
if len(parsed.Choices) == 0 || strings.TrimSpace(parsed.Choices[0].Message.Content) == "" {
|
||
return nil, fmt.Errorf("openai response missing content")
|
||
}
|
||
```
|
||
Ответ **только** с `tool_calls` (без текста) — валидный для OpenAI API, но v2 считает его ошибкой.
|
||
Это надо чинить **первым**.
|
||
|
||
📌 В `norma-local` был импорт `adk` v1; в `runtime v2` — `adk/v2`. `genai.Tool` /
|
||
`FunctionDeclaration` могут отличаться по полям — сверять при переносе.
|
||
|
||
### Принятый план (Alex: «1»)
|
||
|
||
1. Форк `normahq/runtime` → `mallexxx/runtime`, ветка `feat/openai-tool-calls-v2`.
|
||
2. Перенести 4 блока в `hostedagent/openai.go` по образцу `norma-local`.
|
||
3. Применить в Балде: `replace github.com/normahq/runtime/v2 => ./runtime-local` в
|
||
`~/Developer/balda/go.mod` (по образцу старого `norma-local`).
|
||
4. Собрать образ на Mac → залить на Kraken.
|
||
5. E2E: `mmctl --local post list qentra:balda-test` → ждём вызов `executor_run` / `session_memory.search`.
|
||
6. Обновить `personal/projects/balda/balda-valera.md` (патч живёт снова) + этот док.
|
||
|
||
**Альтернатива (отклонена Alex'ом):** перевод `balda.provider` на ACP-тип
|
||
(`claude_code_acp` / `codex_acp`) — инструменты работают из коробки, но меняет модель
|
||
(DeepSeek оплачен и настроен; у Alex есть готовый референс патча).
|
||
|
||
**Мета-вывод:** апстрим переписал `hostedagent/openai.go` под `runtime v2` и **потерял**
|
||
фикс tool_calls. Наш старый патч не переносится автоматически — файл существует, но
|
||
содержимое урезано до chat-completions без tools.
|
||
|
||
---
|
||
|
||
## 16. 🔴 Форк собран и задеплоен — и вскрыл ВТОРОЙ, более глубокий баг (2026-09-27)
|
||
|
||
План §15 выполнен целиком (шаги 1-5). Но E2E дал **не** ожидаемое «инструменты заработали»,
|
||
а новый отказ. Ниже — что сделано, что доказано, и где именно встали.
|
||
|
||
### 16.1 Что выполнено
|
||
|
||
| Шаг | Результат |
|
||
|---|---|
|
||
| Форк `mallexxx/runtime` | ✅ ветка `feat/openai-tool-calls-v2` от тега `v2.0.11`, коммит `733b32d` |
|
||
| PR в апстрим | ✅ **https://github.com/normahq/runtime/pull/4** |
|
||
| Патч `hostedagent/openai.go` | ✅ +324 строки; `openai_test.go` +315 строк (17 тестов) |
|
||
| Чувствительность тестов | ✅ временный откат `TurnComplete`/`parseChatResponse` → падают ровно 2 нужных теста |
|
||
| Линтер | ✅ 0 issues (3 `goconst` + 1 `godot` исправлены) |
|
||
| `replace` в Балде | ✅ `github.com/normahq/runtime/v2 => ./runtime-local` в `go.mod` |
|
||
| `Dockerfile.release` | ✅ `COPY runtime-local/` **до** `go mod download` (иначе слой падает) |
|
||
| Образ | ✅ `balda-mm:0.1.12`, 20 МБ, залит на Kraken, MD5 сверен |
|
||
| **Форк в бинарнике** | ✅ **доказано фактом** — `strings` бинарника из образа даёт `openai response missing content and tool_calls` и `json:"tool_call_id,omitempty"` (в апстриме `v2.0.11` обеих строк НЕТ) |
|
||
|
||
📌 **Как доказать, что код форка попал в образ** (образ `scratch`, shell нет):
|
||
```bash
|
||
docker save balda-mm:0.1.12 -o /tmp/chk.tar
|
||
mkdir -p /tmp/chkx && tar -xf /tmp/chk.tar -C /tmp/chkx
|
||
# OCI-формат: слои в blobs/sha256/*; бинарник лежит в самом большом слое
|
||
for f in /tmp/chkx/blobs/sha256/*; do
|
||
file -b "$f" | grep -q gzip && tar -xzf "$f" -C /tmp/chkx 2>/dev/null
|
||
done
|
||
strings /tmp/chkx/usr/local/bin/balda | grep -E "tool_call_id|missing content and tool_calls"
|
||
```
|
||
⚠️ `tar -tf image.tar | grep ca-certificates` даёт **ложный 0** на OCI-образе — файлы внутри
|
||
слоёв. Так же и с поиском кода форка: смотреть только распакованный бинарник.
|
||
|
||
### 16.2 🔴 Корень: двойная паковка инструментов в ADK `v2.4.0` (исправлено 2026-09-27)
|
||
|
||
**Симптом после деплоя `0.1.12`:** каждый ход падает одинаково, Балда молчит.
|
||
|
||
```
|
||
error … "agent run: duplicate tool: \"balda.control.shutdown\""
|
||
warn … command failed with retryable error (delivery_attempt 1..4)
|
||
warn … publish event "balda.v1.evt.command.in_progress": context deadline exceeded
|
||
```
|
||
|
||
**Это тот же `duplicate tool`, что чинили в §13.** В конфиге дубля НЕТ — проверено:
|
||
`balda.mcp_servers: []`, ID только у провайдера. Значит причина другая.
|
||
|
||
> ### ⚠️ ИСПРАВЛЕНИЕ РАННЕЙ ВЕРСИИ ЭТОГО РАЗДЕЛА (2026-09-27)
|
||
> Ранее здесь было записано: «форк включил второй раунд ADK (`TurnComplete = !hasToolCalls`) и
|
||
> **обнажил** двойную паковку; на `0.1.11` её не было, т.к. инструменты не пакались вообще».
|
||
> **Это ОПРОВЕРГНУТО экспериментом §16.4.** `0.1.11` падает с ТЕМ ЖЕ `duplicate tool` при явном
|
||
> запросе инструмента. Значит:
|
||
> - паковка инструментов идёт **независимо** от `TurnComplete` — она происходит на фазе
|
||
> сборки запроса, **до** первого ответа модели;
|
||
> - второй раунд форка **не является** условием бага;
|
||
> - баг жил в связке Balda↔ADK **всегда**, на обеих версиях.
|
||
> Не воспроизводить старую формулировку: она ведёт к ложному выводу «откат на `0.1.11` лечит».
|
||
|
||
**Механизм — три шага, все по коду ADK `v2.4.0` (прочитано построчно):**
|
||
|
||
**Шаг 1.** `internal/llminternal/tools_processor.go:40-58` — `toolProcessor` **расширяет**
|
||
инструменты каждого toolset'а в `f.Tools`:
|
||
```go
|
||
if f.Tools != nil { return } // защита только от повторного расширения
|
||
tools := Reveal(llmAgent).Tools
|
||
for _, toolSet := range Reveal(llmAgent).Toolsets {
|
||
tsTools, err := toolSet.Tools(icontext.NewReadonlyContext(ctx))
|
||
tools = append(tools, tsTools...) // 🔴 mcpTool'ы скопированы в f.Tools
|
||
}
|
||
f.Tools = tools
|
||
```
|
||
|
||
**Шаг 2.** `internal/llminternal/base_flow.go:765-768` — **оба** прохода подряд над ОДНИМ `req`,
|
||
между ними **нет** защиты:
|
||
```go
|
||
if err := toolPreprocess(ctx, req, f.Tools); err != nil { … } // :778
|
||
if err := toolsetPreprocess(ctx, req); err != nil { … } // :798
|
||
```
|
||
|
||
**Шаг 3.** `tool/mcptoolset/tool.go:88-90` — `mcpTool.ProcessRequest` → `toolutils.PackTool`:
|
||
```go
|
||
// toolutils/toolutils.go:47-48
|
||
if _, ok := req.Tools[name]; ok {
|
||
return fmt.Errorf("duplicate tool: %q", name)
|
||
}
|
||
```
|
||
|
||
**Итог:** `f.Tools` уже содержит `balda.control.shutdown` (расширен на шаге 1) → `toolPreprocess`
|
||
кладёт его в `req.Tools` → `toolsetPreprocess` пытается положить его **второй раз** через
|
||
MCP-toolset → падение.
|
||
|
||
🔴 **Этот механизм ОПРОВЕРГНУТ (2026-09-27, см. §19).** `toolsetPreprocess` **не трогает**
|
||
MCP-toolsets: `mcptoolset.set` **не реализует** `ProcessRequest` — проверено исполняемым
|
||
тестом (`runtime-local/agentfactory/duplicate_toolset_test.go`,
|
||
`TestMcpToolsetImplementsRequestProcessor`). В `adk/v2@v2.4.0/tool/mcptoolset/set.go` метод
|
||
объявлен **только** у `mcpTool` (`tool.go:88`), а не у `set`. Значит
|
||
`if !ok { continue }` в `toolsetPreprocess` пропускает все MCP-тулсеты, и «двойная паковка
|
||
через два слоя» невозможна. Единственный паковщик — `toolPreprocess` по `f.Tools`.
|
||
Реальный корень — **два тулсета на один и тот же эндпоинт** (§19).
|
||
|
||
📌 **Формулировка «дефект архитектуры ADK» — тоже неверна.** Два слоя (`Tools` / `Toolsets`)
|
||
намеренно взаимоисключающие, и защита работает; ADK-контракт нарушила **регистрация** двух
|
||
MCP-серверов с идентичным набором инструментов.
|
||
|
||
📌 **Почему §16.4-кандидат «`Tools` + `Toolsets` в `llmagent.Config`» — тоже не источник:**
|
||
`agentfactory.go:644` строит `toolsets = hostedToolsets(req.Toolsets, resolvedMCP)`, которая
|
||
**копирует** `req.Toolsets` и добавляет по одному toolset'у на MCP-сервер. Балда
|
||
(`internal/apps/balda/agent/builder.go:296-303`) задаёт **только `MCPServerIDs`** — `Tools` и
|
||
`Toolsets` в `BuildRequest` **пустые**. Значит MCP-серверы приходят **исключительно** как
|
||
toolsets, и «дубль в двух полях» невозможен.
|
||
|
||
### 16.3 Что исключено фактами (не повторять)
|
||
|
||
| Версия | Проверка | Итог |
|
||
|---|---|---|
|
||
| Конфиг дублирует MCP-ID | `balda.mcp_servers: []`, ID у провайдера | ❌ чисто |
|
||
| Сессия накопила мусор | `/reset` **от владельца** (пост через admin PAT) | ❌ не лечит, `duplicate tool` остался |
|
||
| Форк не попал в образ | `strings` бинарника из `0.1.12` | ❌ форк на месте |
|
||
| Каталог `state.db` замусорен | уникальные ID: `builtin/balda`, `configured-mcp/executor-mcp`, `configured-mcp/obsidian` — **ровно по одному** | ❌ по одной записи |
|
||
| Bundled MCP стартует дважды | лог: `bundled MCP listener started` — **один раз**, `127.0.0.1:<port>` | ❌ однократно |
|
||
| Мой патч трогает сборку инструментов | `git diff v2.0.11 --stat` → **только** `openai.go` + `openai_test.go`; `agentfactory.go`, `base_flow.go` не тронуты | ❌ патч чист |
|
||
| `ContextBroker.Wrap` добавляет инструменты | `broker.go:118-153` — только HTTP-обёртка заголовков, инструментов не добавляет | ❌ не источник |
|
||
| `hostedToolsets()` дублирует серверы | `agentfactory.go:360-384` — `resolvedMCP` это **map** по `id`, ключ уникален | ❌ дубля нет |
|
||
|
||
⚠️ **Питфолл ложной тревоги:** `strings state.db | grep -c '"balda"'` даёт ~112. Это **счётчик
|
||
вхождений подстроки**, а не число серверов: `balda` встречается в каждом логе, в `balda.v1.*`,
|
||
в `balda.control.shutdown`. Не делать из этого вывод о дублях — считать **уникальные** ID.
|
||
|
||
### 16.4 ✅ A/B-эксперимент на `0.1.11` — ДОВЕДЁН, гипотеза опровергнута (2026-09-27)
|
||
|
||
**Что сделано.** `0.1.11` уже стоял живым (`docker ps` → `balda-mm balda-mm:0.1.11 Up`,
|
||
compose строка 3 = `0.1.11`). Взял токен владельца без пароля и послал пост с **явным**
|
||
требованием вызвать инструмент:
|
||
|
||
```bash
|
||
ssh kraken 'docker exec mattermost mmctl --local token generate admin "ab-011-$(date +%H%M)"'
|
||
# → 86qus47tbfys9xehf4x5yxzuyr: ab-011-0129
|
||
|
||
ssh kraken '/tmp/mm-post-admin.sh 86qus47tbfys9xehf4x5yxzuyr 7bjw4g7o1jdsbmjbqd649xxgaw \
|
||
"@balda вызови инструмент executor_health и покажи результат"'
|
||
# → posted id=hg6f1tgat7n53m345smertii5h user=cbwsorzwoigbtrdnteugnx4aqo
|
||
```
|
||
|
||
**Результат: `0.1.11` падает ТЕМ ЖЕ error'ом.** Лог `docker logs balda-mm --since 5m`:
|
||
|
||
```json
|
||
{"level":"info","component":"balda.handlersfx.mattermost",…,"settlement_stage":"accepted",…}
|
||
{"level":"error","component":"balda.turn_dispatcher",
|
||
"error":"agent run: duplicate tool: \"balda.control.shutdown\"",…,"message":"turn task failed"}
|
||
{"level":"warn","component":"balda.execution_bus","delivery_attempt":1,…,"settlement_class":"retrying:unclassified"}
|
||
```
|
||
|
||
📌 **Авторизация прошла корректно** (`settlement_stage: accepted`, `post_user_id` = Alex) —
|
||
новый метод постинга (§17.1) работает, пост **не** отбит. До падения доходит именно ход агента.
|
||
|
||
**Выводы (durable):**
|
||
|
||
| Утверждение | Статус |
|
||
|---|---|
|
||
| `duplicate tool` вносится вторым раундом форка | ❌ **опровергнуто** — `0.1.11` падает так же |
|
||
| На `0.1.11` инструменты «не пакались вообще» | ❌ **опровергнуто** — паковка идёт до первого ответа модели |
|
||
| Откат на `0.1.11` — рабочее состояние | ❌ **ложно** — на `0.1.11` инструменты вызывать НЕЛЬЗЯ, ход падает |
|
||
| Баг существует независимо от версии образа | ✅ **подтверждено** |
|
||
|
||
> 🔴 **Следствие для стратегии:** держать `0.1.11` «для отката» как рабочий вариант **нельзя** —
|
||
> он падает на любом явном вызове инструмента. Это не безопасная точка отката, а сломанное
|
||
> состояние. Единственный путь — фикс двойной паковки (§18) и `0.1.13`.
|
||
|
||
**Метод воспроизведения (1 команда, без Alex и без GUI):**
|
||
```bash
|
||
TOK=$(ssh kraken 'docker exec mattermost mmctl --local token generate admin "ab-$(date +%s)"' | awk '{print $1}')
|
||
ssh kraken "/tmp/mm-post-admin.sh $TOK 7bjw4g7o1jdsbmjbqd649xxgaw '@balda вызови инструмент executor_health'"
|
||
sleep 45
|
||
ssh kraken 'docker logs balda-mm --since 3m 2>&1 | grep -E "duplicate tool|turn task failed" | tail -3'
|
||
```
|
||
📌 Пост обязан содержать **явное требование вызвать инструмент** — на обычное «скажи одно слово»
|
||
Балда отвечает текстом, инструменты не пакуются, и баг **не проявляется** (что и породило
|
||
ложный вывод «на `0.1.11` всё хорошо»).
|
||
|
||
⚠️ **Побочный эффект откатов:** жёсткое убийство контейнера оставляет незакрытый `state.db-wal`,
|
||
и следующий старт на `0.1.11` идёт ~4.5 мин вместо ~1m11s (переигрывание WAL). Не считать это
|
||
зависанием: `docker logs | wc -l` растёт рывками, процесс в `do_epoll_wait` — живой.
|
||
Признак настоящего зависания — рост лога остановился **и** нет потока в `epoll`.
|
||
|
||
### 16.5 Побочный результат: чистка docker на Mac
|
||
|
||
`docker system df` до: образы 23.23 ГБ (освобождаемо 20.14), build cache 5.91 ГБ.
|
||
Удалено: 10 тегов `balda-mm:0.1.0`…`0.1.9` + `docker builder prune --keep-storage 1GB`.
|
||
Стало: образы 18.83 ГБ, cache 2.05 ГБ. **Оставлены для отката:** `0.1.10`, `0.1.11`, `0.1.12`,
|
||
`balda:mm-classifier`, `balda-executor:0.1.0`, `balda-executor-mcp:0.1.0`+`0.2.0`. Тома не тронуты.
|
||
|
||
📌 **Оценка «~8 ГБ за старые теги» была завышена в 10 раз:** 10 тегов `balda-mm` указывали на
|
||
общие слои, реально освободилось ~0.8 ГБ. Основной вес сидел в build cache (3.86 ГБ) и в
|
||
`balda-executor:0.1.0` (2.48 ГБ). `docker system prune -a` **не применять** — снесёт образы
|
||
для отката и тома с данными.
|
||
|
||
---
|
||
|
||
## 17. 🔴 Как постить в `qentra:balda-test` (без Alex и без GUI)
|
||
|
||
**Приёмка обязана идти от УЧЁТКИ ЗАКАЗЧИКА.** Полный разбор — в
|
||
[[personal/projects/balda/balda-kraken-mm-deploy]] (блок капсом в начале дока). Здесь — рабочий рецепт.
|
||
|
||
### 17.1 От владельца (`admin`) — правильная приёмка
|
||
|
||
```bash
|
||
# токен создаётся ОДИН раз и показывается один раз; --local работает через unix-сокет
|
||
ssh kraken 'docker exec mattermost mmctl --local token generate admin "agent-e2e-$(date +%Y%m%d-%H%M)"'
|
||
# → <token>: agent-e2e-20260927-0043
|
||
|
||
# пост от владельца (собирать имена из кусков — иначе редакция секретов калечит файл)
|
||
# см. /tmp/mm-post-admin.sh, /tmp/mm-feed.sh (git-ignored, живут в /tmp на Kraken)
|
||
ssh kraken '/tmp/mm-post-admin.sh <token> 7bjw4g7o1jdsbmjbqd649xxgaw "@balda <текст>"'
|
||
ssh kraken '/tmp/mm-feed.sh <token>'
|
||
```
|
||
|
||
`mmctl --local token generate` — **вход без пароля**, это ключ к приёмке: пароля `admin`
|
||
в файлах нет нигде, а сессии из БД подставить нельзя (MM отвечает `session_expired`:
|
||
в заголовке нужен хеш сессии, не её `id`).
|
||
|
||
### 17.2 От `testuser` — только проверка конвейера, НЕ приёмка
|
||
|
||
```bash
|
||
ssh kraken '/tmp/mm-post-user.sh 7bjw4g7o1jdsbmjbqd649xxgaw testuser "T3stPassw0rd!x" "@balda <текст>"'
|
||
```
|
||
`testuser` = `c6udeze8dfrdxm5jbgq55bd58r` — **не владелец** → `settlement_stage=unauthorized`.
|
||
Пост доходит до конвейера и корректно отбивается. Доказывает маршрут, **не** работу для заказчика.
|
||
|
||
### 17.3 Что НЕ работает (проверено)
|
||
|
||
| Путь | Ошибка |
|
||
|---|---|
|
||
| `mmctl --local post create qentra:balda-test --message …` | `creating posts is not supported in local mode` |
|
||
| `mmctl --local post create --channel <id>` | `unknown flag: --channel` (цель — `team:channel` позиционно) |
|
||
| Сессия `admin` из БД как токен | `api.context.session_expired.app_error` |
|
||
| `backoffice-adminpw` как пароль MM | 401 — это пароль **backoffice Балды**, не Mattermost |
|
||
| Пароль `admin` в файлах Kraken | **отсутствует** ни в одном файле — не искать |
|
||
|
||
✅ **Работает:** `mmctl --local post list qentra:balda-test` — **чтение ленты** (создание нельзя,
|
||
чтение можно). Это и есть «читать сессию из базы» без GUI.
|
||
|
||
### 17.4 🔴 Забытое искать в ZULIP БАЗЕ, не в `session_search`
|
||
|
||
Пароль `testuser` был потерян при компакции контекста. `session_search` вернул **ноль**.
|
||
Ответ дал запрос по Zulip PostgreSQL:
|
||
|
||
```bash
|
||
docker exec zulip-database-1 psql -U zulip zulip -c "
|
||
SELECT m.id, m.date_sent,
|
||
REPLACE(REPLACE(LEFT(m.content,300),E'\n',' '),'|','/') as content
|
||
FROM zerver_message m
|
||
JOIN zerver_recipient r ON m.recipient_id = r.id
|
||
WHERE r.type = 2 AND m.content ILIKE '%mm-post-user%'
|
||
ORDER BY m.date_sent DESC LIMIT 10;"
|
||
# → вернул точную команду с паролем: mm-post-user.sh … testuser "T3stPassw0rd!x" …
|
||
```
|
||
|
||
📌 `session_search` индексирует сессии Hermes, **не Zulip**. Сказано «из базы» → сразу
|
||
`docker exec psql`. Скилл: `zulip-db-forensics`. Тот же урок — в
|
||
[[personal/projects/balda/balda-kraken-mm-deploy]].
|
||
|
||
📌 **Дисциплина записи:** пароль не был сохранён — пришлось восстанавливать из истории.
|
||
Команды, которыми делается приёмка, сохранять в док **вместе с параметрами**.
|
||
|
||
---
|
||
|
||
## 18. 🔴 Фикс двойной паковки — план (2026-09-27)
|
||
|
||
> ⚠️ **Раздел сохранён как история решения. Диагноз здесь (Option A) ОШИБОЧЕН —
|
||
> см. §19 с настоящим корнем и настоящим фиксом.** Option A правил `openAIConstructor`
|
||
> под гипотезу «двойная паковка через два слоя», которая опровергнута исполняемым тестом.
|
||
> Фикс в итоге сделан в **`hostedToolsets`**, а не в конструкторах.
|
||
|
||
**Контекст:** корень доказан (§16.2), A/B проведён (§16.4). Фикс **не начат** — правка кода
|
||
в форке требует подтверждения Alex (правило: план → апрув → работа).
|
||
|
||
### 18.1 Где чинить и почему НЕ в Балде
|
||
|
||
| Вариант | Вердикт |
|
||
|---|---|
|
||
| Патч ADK `base_flow.go` | ❌ **невозможно без вендоринга** — `google.golang.org/adk/v2 v2.4.0` это модульная зависимость (`go.mod:27`), файлы в `$GOMODCACHE`, read-only |
|
||
| Патч `internal/apps/balda/*` | ❌ Балда задаёт только `MCPServerIDs` (`builder.go:296-303`), `Tools`/`Toolsets` пустые — дублировать нечего, чинить нечего |
|
||
| **Патч форка `runtime-local/agentfactory/agentfactory.go`** | ✅ **единственная доступная точка** — форк уже подключён через `replace` в `go.mod`, мы им владеем |
|
||
|
||
### 18.2 Суть фикса (Option A)
|
||
|
||
`toolProcessor` (§16.2 шаг 1) **сам** расширяет toolsets в `f.Tools` и при этом ставит
|
||
`f.Tools != nil` — после чего защита `if f.Tools != nil { return }` срабатывает и
|
||
повторное расширение не происходит. Но `toolsetPreprocess` вызывается **безусловно** и
|
||
пакует те же инструменты второй раз.
|
||
|
||
**Решение:** не отдавать MCP-toolsets одновременно и как `Toolsets`. Для
|
||
`openAIConstructor` / `aistudioConstructor`:
|
||
|
||
- `Toolsets:` передавать только `req.Toolsets` (без `hostedToolsets(...)`),
|
||
- MCP-инструменты добавлять в `Tools` — тогда `f.Tools != nil` и `toolsetPreprocess`
|
||
останется без MCP-toolsets, которые всё равно уже расширены в `Tools`.
|
||
|
||
**Файл:** `runtime-local/agentfactory/agentfactory.go`
|
||
**Функции:** `openAIConstructor` (:635-658), `aistudioConstructor` (:660-685) — **обе**
|
||
(симметричный дефект), плюс `hostedToolsets` (:360-384) как источник.
|
||
|
||
### 18.3 Порядок работ (после апрува)
|
||
|
||
1. Правка `agentfactory.go` + тесты в `agentfactory/agentfactory_test.go`.
|
||
2. Гейты: `go build ./...`, `go test ./...`, `go tool golangci-lint run ./...` (по `AGENTS.md` форка).
|
||
3. **Тест на регрессию обязателен:** тест, который падает на текущем коде с
|
||
`duplicate tool` и проходит после фикса.
|
||
4. Сборка образа на Mac: `docker buildx build --builder arm64builder --platform linux/arm64
|
||
-f Dockerfile.release --provenance=false --load -t balda-mm:0.1.13 .`
|
||
5. `docker save` → `scp` → `docker load` на Kraken → `tools/mm-switch-image.sh` →
|
||
`docker compose up -d --force-recreate`.
|
||
6. Приёмка (§16.4 метод): пост от владельца **с явным вызовом инструмента** →
|
||
ждём `executor_health` в ответе и **отсутствие** `duplicate tool` в логе.
|
||
7. Обновить §0 (6h → ✅) и Фазу 3 в [[personal/projects/balda/balda-kraken-mm-deploy]].
|
||
|
||
### 18.4 🔴 `0.1.11` — НЕ точка отката
|
||
|
||
По итогам A/B (§16.4): на `0.1.11` любой ход с явным вызовом инструмента падает с тем же
|
||
`duplicate tool`. Держать его как «безопасный откат» **нельзя**. Образы для отката:
|
||
`0.1.10` (отвечает текстом, инструменты мертвы) — единственный, где бот **отвечает**.
|
||
|
||
### 18.5 Открытый вопрос Alex
|
||
|
||
Правку `runtime-local/agentfactory/agentfactory.go` (Option A) писать сейчас?
|
||
|
||
---
|
||
|
||
## 19. ✅🔴 НАСТОЯЩИЙ КОРЕНЬ и ФИКС — два тулсета на один эндпоинт (2026-09-27)
|
||
|
||
> Сессия после §18. Alex: «Чё за тупые вопросы опять?! Чини блядь!!» → писать код без
|
||
> повторных апрувов. Диагноз §16.2/§18 **опровергнут**, найден истинный корень, фикс написан.
|
||
|
||
### 19.1 Корень — Balda регистрирует bundled-сервер ДВАЖДЫ на одном URL
|
||
|
||
`internal/apps/balda/internalmcp/manager.go` использует **один и тот же адрес** в двух местах:
|
||
|
||
```go
|
||
// :206 — база для session-scoped брокера (URL эндпоинта + ?token)
|
||
m.contextBroker.SetBaseURL("http://" + res.Addr + baldaRoute)
|
||
// :213 — регистрация bundled-сервера
|
||
m.registry.Set(bundledBaldaServerID, agentconfig.MCPServerConfig{
|
||
Type: agentconfig.MCPServerTypeHTTP,
|
||
URL: fmt.Sprintf("http://%s%s", res.Addr, baldaRoute),
|
||
})
|
||
```
|
||
|
||
`sessionmemorymcp/broker.go`:
|
||
- `Bind()` (`:111`) отдаёт `ID: "balda-session-memory-"+token`, а `URL` (через `contextURL`,
|
||
`:216-225`) — **тот же эндпоинт** плюс `?<contextQueryParameter>=<token>`.
|
||
- `Wrap()` (`:118-153`) — тот же handler, инжектит trusted-заголовки по токену.
|
||
|
||
`internal/apps/balda/agent/runtime_manager.go:344-345` регистрирует привязку и **добавляет её ID
|
||
в `extraMCPServerIDs`**:
|
||
|
||
```go
|
||
registry.Set(binding.ID, binding.Config)
|
||
extraMCPServerIDs = append(extraMCPServerIDs, binding.ID)
|
||
```
|
||
|
||
**Следствие:** `hostedToolsets` строит **два MCP-тулсета на один и тот же сервер**. Оба отдают
|
||
одинаковый список инструментов (включая `balda.control.shutdown`). `toolProcessor` складывает
|
||
оба набора в один плоский `f.Tools`, `toolPreprocess` пакует первый → OK, второй → `PackTool`
|
||
(`toolutils.go:47-48`) видит занятое имя → **`duplicate tool`**.
|
||
|
||
| Факт | Значение |
|
||
|---|---|
|
||
| Триггер | `session_memory.enabled: true` (создаёт привязку) **и** ход с явным вызовом инструмента |
|
||
| Почему на `0.1.11` «тихо» | Балда отвечала текстом → инструменты не пакались → баг не всплывал |
|
||
| Почему A/B дал падение | Пост содержал **явное требование вызвать инструмент** |
|
||
| Связь с форком | **Никакой** — дефект регистрации в Балде, существовал всегда |
|
||
|
||
### 19.2 Что опровергнуто исполняемым тестом (не возвращаться)
|
||
|
||
| Версия | Проверка | Итог |
|
||
|---|---|---|
|
||
| §16.2: `toolsetPreprocess` пакует MCP-тулсеты второй раз | `TestMcpToolsetImplementsRequestProcessor` → `mcptoolset.set` **не** реализует `ProcessRequest` | ❌ невозможно |
|
||
| §16.2: «дефект архитектуры ADK» | Защита `if f.Tools != nil { return }` работает as designed | ❌ контракт нарушила регистрация |
|
||
| §18 Option A: править `openAIConstructor`/`aistudioConstructor` | Гипотеза двойной паковки ложна | ❌ фикс не там |
|
||
| `hostedToolsets` дублирует `resolvedMCP` | `resolvedMCP` — map по `id`, ключ уникален | ❌ дубля нет (но два **разных** ключа на один URL — да) |
|
||
|
||
📌 **Метод, который дал ответ:** не чтение ADK, а **исполняемый тест** на присутствие
|
||
интерфейса. `go test ./agentfactory/ -run TestMcpToolsetImplementsRequestProcessor -v`
|
||
→ `MCP toolset "mcp_tool_set" does NOT implement ProcessRequest`. Час чтения исходников
|
||
не дал того, что дал один тест.
|
||
|
||
### 19.3 Фикс — дедуп тулсетов по эндпоинту (написан 2026-09-27)
|
||
|
||
**Файл:** `runtime-local/agentfactory/agentfactory.go`, функция `hostedToolsets`.
|
||
|
||
Идея: два конфига, адресующие **один host+path**, — это один и тот же сервер с точки зрения
|
||
пространства имён инструментов. Различие в query-строке (контекст-токен Балды) не должно делать
|
||
их разными.
|
||
|
||
```go
|
||
// схлопываем эндпоинты, отличающиеся только query
|
||
keeperOf := make(map[string]string, len(ids))
|
||
skip := make(map[string]bool, len(ids))
|
||
for _, id := range ids {
|
||
key := mcpEndpointKey(resolvedMCP[id])
|
||
if key == "" { continue }
|
||
keeper, ok := keeperOf[key]
|
||
if !ok { keeperOf[key] = id; continue }
|
||
// оставляем БОГАТУЮ запись: scoped-URL несёт контекст-токен → супернабор
|
||
if mcpURLIsScoped(resolvedMCP[id]) && !mcpURLIsScoped(resolvedMCP[keeper]) {
|
||
skip[keeper] = true; keeperOf[key] = id; continue
|
||
}
|
||
skip[id] = true
|
||
}
|
||
```
|
||
|
||
Новые хелперы:
|
||
- `mcpEndpointKey(cfg)` — `url.Parse`, схема+host+path, **`RawQuery` и `Fragment` обнуляются**.
|
||
- `mcpURLIsScoped(cfg)` — есть ли query (признак session-scoped привязки Балды).
|
||
|
||
🔴 **Критично, какую запись оставлять.** Наивный вариант «первый по алфавиту» оставляет `balda`
|
||
и **выбрасывает scoped-привязку** — фикс перестаёт падать, но **ломает session memory**
|
||
(теряется контекст-токен). Поэтому keeper — запись **со** scoped-URL: она строгий супернабор
|
||
(инжектит заголовки, а без токена `Wrap` просто пропускает запрос через `next.ServeHTTP`).
|
||
|
||
### 19.4 Тесты (написаны)
|
||
|
||
`runtime-local/agentfactory/duplicate_toolset_test.go`:
|
||
|
||
| Тест | Что проверяет |
|
||
|---|---|
|
||
| `TestHostedToolsetsCollapsesScopedEndpointDuplicate` | 2 записи на один URL → **1** тулсет |
|
||
| `TestHostedToolsetsKeepsDistinctEndpoints` | `balda` + `executor-mcp` + `obsidian` → **3** тулсета (защита от сверх-схлопывания) |
|
||
| `TestMCPEndpointKey` | `?ctx=tok`, `?a=1&b=2` → ключ без query |
|
||
| `TestMcpToolsetImplementsRequestProcessor` | фиксирует факт: `mcptoolset.set` **не** имеет `ProcessRequest` |
|
||
|
||
### 19.5 Состояние и остаток работ
|
||
|
||
Гейты на момент записи: `go build ./...` → **OK**, `go vet ./agentfactory/` → **OK**.
|
||
Тесты **не прогнаны** — команда ниже.
|
||
|
||
⚠️ **На Kraken сейчас стоит диагностический образ `0.1.13-diag`** (с временными `fmt.Printf`),
|
||
не `0.1.11`. Бэкап compose: `docker-compose.yml.bak-diag-<ts>`. Диагностические логи в
|
||
`hostedagent/openai.go` и `internal/apps/balda/agent/builder.go` **откачены** (в дереве чисто).
|
||
|
||
Остаток:
|
||
1. `cd ~/Developer/balda/runtime-local && go test ./agentfactory/ -run 'Toolset|MCPEndpoint' -v`
|
||
2. Собрать `balda-mm:0.1.13` (без `-diag`), залить, `docker compose up -d --force-recreate`.
|
||
3. Приёмка от владельца с **явным вызовом инструмента** → ждём ответ и **отсутствие**
|
||
`duplicate tool` в логе.
|
||
4. Вернуть compose на актуальный тег (сейчас `0.1.13-diag`).
|
||
5. §0: 6h → ✅; Фаза 3 в [[personal/projects/balda/balda-kraken-mm-deploy]].
|
||
|
||
### 19.6 🔴 Kraken перегружен — docker-операции идут минутами
|
||
|
||
Во время сессии `load average` поднимался до **8.03 при 4 ядрах** (нет одного CPU-хога — это
|
||
I/O-wait на HDD). Симптомы, которые **не** являются дефектом Балды:
|
||
|
||
| Симптом | Объяснение |
|
||
|---|---|
|
||
| `docker compose up -d` не отвечает >5 мин | хост thrashing, не зависание |
|
||
| `docker rm -f` → `removal … already in progress` | команда реально идёт, просто медленно |
|
||
| Контейнер в статусе `Dead` несколько минут | штатное промежуточное состояние при убитом recreate |
|
||
| Старт Балды ~4.5 мин вместо ~1м11с | переигрывание `state.db-wal` (§16.4) |
|
||
|
||
📌 **Правило:** на Kraken не повторять docker-команды «потому что долго» — это удваивает
|
||
нагрузку. Проверять `uptime` и `ps -eo pcpu,pid,comm --sort=-pcpu`.
|
||
|
||
⚠️ **Прерванный `docker compose up -d --force-recreate` оставляет мусор:** полу-созданный
|
||
контейнер `<hash>_balda-mm` + старый в `Dead`. Лечится `docker rm -f` на оба (медленно).
|
||
|
||
---
|
||
|
||
## Связанные заметки
|
||
- [[personal/projects/balda/balda-mm-delegation-plan]] — общий план (Фазы 4-5)
|
||
- [[personal/projects/balda/balda-kraken-mm-deploy]] — Фаза 3 (✅)
|
||
- [[personal/plans/balda-mm-pr-plan]] — Фаза 2 (PR #8)
|
||
- [[personal/tech/balda-executor-mcp]] — MCP-сервер делегирования (шаг 5)
|
||
- [[personal/tech/agents-bridge]] — мост :8790 (шаг 2)
|
||
- [[personal/tech/eagle-dashboard-agents-tab]] — секция «Agents» в дашборде (шаг 4)
|
||
- [[personal/tech/macos-docker-virtiofs-mount-limits]] — ограничения macOS Docker
|
||
- [[personal/tech/balda-go-fx-pitfalls]] — fx-питфоллы, дельта-апплаер
|
||
- [[family/how-to/kraken-portainer-access]] — cloudflared, routes
|
||
- скилл `eagle-dashboard` — архитектура дашборда
|