Files
obsidian-vault/personal/plans/balda-mm-phase4-agents.md
T

612 lines
36 KiB
Markdown
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Фаза 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 |
| 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` берётся из `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`).
4. 🔴 Логин CLI (Alex вручную) — без него сквозная приёмка невозможна.
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 …` | ⏳ за Alex'ом |
---
## Связанные заметки
- [[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` — архитектура дашборда