48 KiB
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-26 — в runtime v2 вырезан tool_calls для openai-провайдера (та же дыра, что закрывали в norma-local). План принят Alex'ом: форк + патч. См. §15 |
| 7 | Приёмка от аккаунта Alex | 🔴 блокер: авторизация CLI |
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берётся изProgramArgumentsplist,.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 отвалится молча. Туннель даёт стабильное имя и не привязан к топологии сети.
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.
# применить: правка 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. Открыто
- ✅ Конфиг Балды переведён на 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. - ✅ Obsidian прописан в конфиг Балды — контейнер
obsidian-mcpна Kraken поднят,runtime.mcp_servers.obsidianдобавлен. ⚠️ Тип —sse, неhttp:См. §11 ниже.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 - ✅ 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). - 🔴 Логин CLI (Alex вручную) — без него сквозная приёмка невозможна.
- 🔴 Завести Access Application на
agents-bridge.qentra.top— сейчас туннель отвечает 200 без авторизации (проверено). Защита — толькоX-Bridge-Secret. - ⚠️
~/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 при живом контейнере. Проверять так:
# из контейнера в 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 игнорируется.
runtime:
providers:
deepseek:
type: openai
openai: {…}
mcp_servers: # 🔴 обязательная секция
- executor-mcp
- obsidian
Звено 2 (главное): 🔴 balda.session_memory.enabled — выключатель MCP-биндера сессии.
Биндер собирается в composition root:
// 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 разойдётся с рантаймом.
// 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.
Проверка (гейты)
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 и прогнать те же
пакеты на базовой ревизии.
Деплой
# сборка (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>.
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 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 держим только у провайдера.
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 по ленте
# имена: 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-сети
# 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.
# 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:
// :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 пуст:
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»)
- Форк
normahq/runtime→mallexxx/runtime, веткаfeat/openai-tool-calls-v2. - Перенести 4 блока в
hostedagent/openai.goпо образцуnorma-local. - Применить в Балде:
replace github.com/normahq/runtime/v2 => ./runtime-localв~/Developer/balda/go.mod(по образцу старогоnorma-local). - Собрать образ на Mac → залить на Kraken.
- E2E:
mmctl --local post list qentra:balda-test→ ждём вызовexecutor_run/session_memory.search. - Обновить
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.
Связанные заметки
- 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— архитектура дашборда