975 lines
63 KiB
Markdown
Executable File
975 lines
63 KiB
Markdown
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` на `0.1.12` — второй, более глубокий баг | 🟡 **корень локализован 2026-09-27** — форк включил второй раунд ADK (`TurnComplete = !hasToolCalls`) и **обнажил** двойную паковку `balda.control.shutdown` (`Tools` + `Toolset`). На `0.1.11` не проявлялся, т.к. инструменты не пакались вообще. 8 версий исключены фактами. См. §16 |
|
||
| 6g | A/B-эксперимент откатом на `0.1.11` | ⏸ **не доведён** — `0.1.11` поднят и стартовал, пост-сравнение не отправлено. Следующее действие. См. §16.4 |
|
||
| 7 | Приёмка от аккаунта Alex | 🟡 **метод готов** — `mmctl --local token generate admin` даёт токен владельца без пароля; пост от `admin` проходит авторизацию (`settlement_stage: accepted`). Блокер — ход падает на `duplicate tool` (п. 6f). См. §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 🔴 Форк не «сломал» Балду — он ОБНАЖИЛ скрытый баг
|
||
|
||
**Симптом после деплоя `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 только у провайдера. Значит причина другая.
|
||
|
||
**Механизм (по коду ADK `v2.4.0`):**
|
||
```
|
||
base_flow.go:778 toolPreprocess() — пакует agent.Tools → req.Tools
|
||
base_flow.go:798 toolsetPreprocess() — пакует Toolsets → req.Tools
|
||
└─ mcptoolset/tool.go:89 → PackTool() → toolutils.go:47-48
|
||
if _, ok := req.Tools[name]; ok { return fmt.Errorf("duplicate tool: %q", name) }
|
||
```
|
||
`tools_processor.go:31` — `if f.Tools != nil { return }` — защищает **только** сборку `f.Tools`,
|
||
но **не** повторный вызов `toolsetPreprocess`. Если инструмент присутствует и в `Tools`,
|
||
и в одном из `Toolsets` (либо в двух toolsets) — второй проход падает.
|
||
|
||
**Почему на `0.1.11` этого не было:** апстримный `openai.go` жёстко ставил `TurnComplete: true`
|
||
(:107). Агент завершал ход после **первого** ответа модели — **инструменты не пакались вообще**.
|
||
Ошибки не было, потому что не было второго раунда. Но и инструменты не работали — что и было
|
||
исходной проблемой (§14, «серверы перечислены, инструменты не исполняются»).
|
||
|
||
> **Вывод:** на `0.1.11` Балда отвечала, но инструменты были мертвы. Форк включил второй раунд
|
||
> (`TurnComplete = !hasToolCalls`) → паковка началась → **вскрылся баг сборки инструментов,
|
||
> который жил в связке Balda↔ADK всё время.** Форк не регрессия — форк диагност.
|
||
|
||
### 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 Где встали и что осталось
|
||
|
||
**Незавершённый эксперимент (следующее действие).** Оставлен откат на `0.1.11` для A/B:
|
||
если там `duplicate tool` повторяется при **явном** вызове инструмента → баг был всегда;
|
||
если нет → он вносится вторым раундом. На момент записи: `0.1.11` поднят и стартовал
|
||
(`Balda bot started`), пост-сравнение не отправлен.
|
||
|
||
**Рекомендованный порядок:**
|
||
1. Довести A/B на `0.1.11` (пост от владельца, см. §17 — как постить).
|
||
2. Найти, что даёт **вторую регистрацию** `balda.control.shutdown`: кандидаты —
|
||
bundled-`balda` попадает и как `Tools`, и как `Toolset` одновременно
|
||
(`agentfactory.go:655-656`: `Tools: req.Tools` + `Toolsets: toolsets`, оба в
|
||
`llmagent.Config`); либо `balda` присутствует в двух toolsets.
|
||
3. Оценить фикс **в апстриме ADK/`agentfactory`**, а не в Балде: `PackTool` идемпотентным не
|
||
является by design, значит дублировать источник нельзя.
|
||
4. Вернуть `0.1.12` после фикса.
|
||
|
||
⚠️ **Побочный эффект откатов:** жёсткое убийство контейнера оставляет незакрытый `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]].
|
||
|
||
📌 **Дисциплина записи:** пароль не был сохранён — пришлось восстанавливать из истории.
|
||
Команды, которыми делается приёмка, сохранять в док **вместе с параметрами**.
|
||
|
||
---
|
||
|
||
## Связанные заметки
|
||
- [[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` — архитектура дашборда
|