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

98 KiB
Executable File
Raw Blame History

[!WARNING] Историческая схема Balda Не использовать пути, контейнеры, provider или MCP transport из этой заметки для текущего запуска. Канонический текущий runbook: personal/projects/balda/setup. Актуально: balda-mm-mac, /Users/admin/Docker/balda-mm-mac/config/config.yaml, Mattermost, Codex ACP, Obsidian type: http → http://host.docker.internal:3101/mcp.

Фаза 4 — исполнитель на Mac + ~/agents + секция «Agents» в Eagle Dashboard

План + статус. Старт 2026-09-26. ⛔ 2026-09-29: старый стек СНЕСЁН. Balda-инстанс на Kraken (balda-mm), MCP-сервер balda-executor-mcp и obsidian-mcp на Kraken удалены вместе со всеми образами balda-mm:*. Схема «Kraken-оркестратор + Mac-исполнитель через мост» закрыта — топология переехала целиком на Mac. Актуальный план: personal/plans/balda-callee-prism-plan. Разделы ниже — история, к работе не применять. Часть 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, replace в go.mod, образ balda-mm:0.1.12. См. §15-16
6f 🔴 duplicate tool — КОРЕНЬ НАЙДЕН И ДОКАЗАН (2026-09-27) ✅ корень: Balda регистрирует bundled-сервер ДВАЖДЫ на одном URL (balda + balda-session-memory-<token>), hostedToolsets строит 2 тулсета с одинаковыми инструментами → PackTool падает. ⚠️ Прежняя версия («двойная паковка в ADK через toolPreprocess+toolsetPreprocess») ОПРОВЕРГНУТА исполняемым тестом — mcptoolset.set не реализует ProcessRequest. См. §19
6g A/B-эксперимент откатом на 0.1.11 ✅ доведён 2026-09-27 — 0.1.11 падает С ТЕМ ЖЕ duplicate tool. Гипотеза §16.2 («форк обнажил») ОПРОВЕРГНУТА: баг был всегда, независимо от раунда. См. §16.4
6h Фикс дублирующегося тулсета ✅ ИСПРАВЛЕНО 2026-09-27, balda-mm:0.1.13 задеплоен — дедуп по эндпоинту в hostedToolsets (runtime-local/agentfactory/agentfactory.go) + 6 тестов. go build/go vet/go test ./... ✅. Приёмка от владельца: @balda вызови executor_health → ответ **executor_health:** ok, turn_complete: true, duplicate tool: = 0, deadletter = 0. См. §19.3-19.5
7 Приёмка от аккаунта Alex ✅ ПРОЙДЕНА 2026-09-27 — пост владельца cbwsorzwoigbtrdnteugnx4aqo → ответ бота edxrgupoxiyig5gzmr79tjypbme: **executor_health:** ok. settlement_stage: accepted, turn_complete: true, инструмент исполнился. См. §17, §19.5
8 CLI исполнителя НЕ залогинен ⛔ СНЯТО 2026-09-29 — неактуально. Схема claude -p по подписке отменена: провайдер обеих сторон переводится на DeepSeek, блокер логина исчезает (нечего логинить). Новая схема — callee + prism, план: personal/plans/balda-callee-prism-plan. Разбор дефекта оставлен в §20 (история).

🔴 2026-09-29 — смена схемы Фазы 4. Alex: «Давай пока на deepseek с обоих концов соберём» + «надо приколбасить prism для таск-трекинга». Оркестрация передаётся callee (документированная архитектура вместо самодельной), таск-трекинг — prism + Beads, обе стороны на DeepSeek. Мост :8790 сохраняется, меняется только argv-таблица. Актуальный план: personal/plans/balda-callee-prism-plan. Разделы §20 и «Два пути фикса» ниже — история, к работе не применять.

Найдено попутно (2026-09-29): одобрения в Mattermost РАБОТАЮТ. ask для MM не «fails closed», как утверждает docs/architecture/interactive-questions.md:275 — в коде permissions/service.go канал mattermost есть в списке поддержанных. Ответ на вопрос приходит реплаем в тред (BuildInboundReply через RootID, channel/mattermost/inbound.go). Реакции и инлайн-кнопки в MM не поддержаны — только Telegram. 🔴 Документ апстрима устарел — поправить в ветке local/*.


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 отвалится молча. Туннель даёт стабильное имя и не привязан к топологии сети.

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. Открыто

  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:
    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 вручную) — ЭТО ЕДИНСТВЕННЫЙ ОСТАВШИЙСЯ БЛОКЕР Фазы 4 (перепроверено 2026-09-28). Прежняя формулировка «сквозная приёмка упирается в duplicate tool (§16)» устарела — тот дефект закрыт 27.09 (0.1.13, §19.5). Сейчас путь такой: конвейер целиком исправен, executor_health вызывается и отвечает, но любая настоящая задача падает на Not logged in · Please run /login — потому что CLI в контейнере-исполнителе не авторизован. Полный диагноз и два пути фикса — §20.
  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 при живом контейнере. Проверять так:

# из контейнера в 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»)

  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 нет):

docker save balda-mm:0.1.12 -o /tmp/chk.tar
mkdir -p /tmp/chkx && tar -xf /tmp/chk.tar -C /tmp/chkx
# OCI-формат: слои в blobs/sha256/*; бинарник лежит в самом большом слое
for f in /tmp/chkx/blobs/sha256/*; do
  file -b "$f" | grep -q gzip && tar -xzf "$f" -C /tmp/chkx 2>/dev/null
done
strings /tmp/chkx/usr/local/bin/balda | grep -E "tool_call_id|missing content and tool_calls"

⚠️ tar -tf image.tar | grep ca-certificates даёт ложный 0 на OCI-образе — файлы внутри слоёв. Так же и с поиском кода форка: смотреть только распакованный бинарник.

16.2 🔴 Корень: двойная паковка инструментов в ADK v2.4.0 (исправлено 2026-09-27)

Симптом после деплоя 0.1.12: каждый ход падает одинаково, Балда молчит.

error … "agent run: duplicate tool: \"balda.control.shutdown\""
warn  … command failed with retryable error   (delivery_attempt 1..4)
warn  … publish event "balda.v1.evt.command.in_progress": context deadline exceeded

Это тот же duplicate tool, что чинили в §13. В конфиге дубля НЕТ — проверено: balda.mcp_servers: [], ID только у провайдера. Значит причина другая.

⚠️ ИСПРАВЛЕНИЕ РАННЕЙ ВЕРСИИ ЭТОГО РАЗДЕЛА (2026-09-27)

Ранее здесь было записано: «форк включил второй раунд ADK (TurnComplete = !hasToolCalls) и обнажил двойную паковку; на 0.1.11 её не было, т.к. инструменты не пакались вообще». Это ОПРОВЕРГНУТО экспериментом §16.4. 0.1.11 падает с ТЕМ ЖЕ duplicate tool при явном запросе инструмента. Значит:

  • паковка инструментов идёт независимо от TurnComplete — она происходит на фазе сборки запроса, до первого ответа модели;
  • второй раунд форка не является условием бага;
  • баг жил в связке Balda↔ADK всегда, на обеих версиях. Не воспроизводить старую формулировку: она ведёт к ложному выводу «откат на 0.1.11 лечит».

Механизм — три шага, все по коду ADK v2.4.0 (прочитано построчно):

Шаг 1. internal/llminternal/tools_processor.go:40-58 — toolProcessor расширяет инструменты каждого toolset'а в f.Tools:

if f.Tools != nil { return }              // защита только от повторного расширения
tools := Reveal(llmAgent).Tools
for _, toolSet := range Reveal(llmAgent).Toolsets {
    tsTools, err := toolSet.Tools(icontext.NewReadonlyContext(ctx))
    tools = append(tools, tsTools...)     // 🔴 mcpTool'ы скопированы в f.Tools
}
f.Tools = tools

Шаг 2. internal/llminternal/base_flow.go:765-768 — оба прохода подряд над ОДНИМ req, между ними нет защиты:

if err := toolPreprocess(ctx, req, f.Tools); err != nil { … }   // :778
if err := toolsetPreprocess(ctx, req); err != nil { … }         // :798

Шаг 3. tool/mcptoolset/tool.go:88-90 — mcpTool.ProcessRequest → toolutils.PackTool:

// toolutils/toolutils.go:47-48
if _, ok := req.Tools[name]; ok {
    return fmt.Errorf("duplicate tool: %q", name)
}

Итог: f.Tools уже содержит balda.control.shutdown (расширен на шаге 1) → toolPreprocess кладёт его в req.Tools → toolsetPreprocess пытается положить его второй раз через MCP-toolset → падение.

🔴 Этот механизм ОПРОВЕРГНУТ (2026-09-27, см. §19). toolsetPreprocess не трогает MCP-toolsets: mcptoolset.set не реализует ProcessRequest — проверено исполняемым тестом (runtime-local/agentfactory/duplicate_toolset_test.go, TestMcpToolsetImplementsRequestProcessor). В adk/v2@v2.4.0/tool/mcptoolset/set.go метод объявлен только у mcpTool (tool.go:88), а не у set. Значит if !ok { continue } в toolsetPreprocess пропускает все MCP-тулсеты, и «двойная паковка через два слоя» невозможна. Единственный паковщик — toolPreprocess по f.Tools. Реальный корень — два тулсета на один и тот же эндпоинт (§19).

📌 Формулировка «дефект архитектуры ADK» — тоже неверна. Два слоя (Tools / Toolsets) намеренно взаимоисключающие, и защита работает; ADK-контракт нарушила регистрация двух MCP-серверов с идентичным набором инструментов.

📌 Почему §16.4-кандидат «Tools + Toolsets в llmagent.Config» — тоже не источник: agentfactory.go:644 строит toolsets = hostedToolsets(req.Toolsets, resolvedMCP), которая копирует req.Toolsets и добавляет по одному toolset'у на MCP-сервер. Балда (internal/apps/balda/agent/builder.go:296-303) задаёт только MCPServerIDs — Tools и Toolsets в BuildRequest пустые. Значит MCP-серверы приходят исключительно как toolsets, и «дубль в двух полях» невозможен.

16.3 Что исключено фактами (не повторять)

Версия Проверка Итог
Конфиг дублирует MCP-ID balda.mcp_servers: [], ID у провайдера ❌ чисто
Сессия накопила мусор /reset от владельца (пост через admin PAT) ❌ не лечит, duplicate tool остался
Форк не попал в образ strings бинарника из 0.1.12 ❌ форк на месте
Каталог state.db замусорен уникальные ID: builtin/balda, configured-mcp/executor-mcp, configured-mcp/obsidian — ровно по одному ❌ по одной записи
Bundled MCP стартует дважды лог: bundled MCP listener started — один раз, 127.0.0.1:<port> ❌ однократно
Мой патч трогает сборку инструментов git diff v2.0.11 --stat → только openai.go + openai_test.go; agentfactory.go, base_flow.go не тронуты ❌ патч чист
ContextBroker.Wrap добавляет инструменты broker.go:118-153 — только HTTP-обёртка заголовков, инструментов не добавляет ❌ не источник
hostedToolsets() дублирует серверы agentfactory.go:360-384 — resolvedMCP это map по id, ключ уникален ❌ дубля нет

⚠️ Питфолл ложной тревоги: strings state.db | grep -c '"balda"' даёт ~112. Это счётчик вхождений подстроки, а не число серверов: balda встречается в каждом логе, в balda.v1.*, в balda.control.shutdown. Не делать из этого вывод о дублях — считать уникальные ID.

16.4 ✅ A/B-эксперимент на 0.1.11 — ДОВЕДЁН, гипотеза опровергнута (2026-09-27)

Что сделано. 0.1.11 уже стоял живым (docker ps → balda-mm balda-mm:0.1.11 Up, compose строка 3 = 0.1.11). Взял токен владельца без пароля и послал пост с явным требованием вызвать инструмент:

ssh kraken 'docker exec mattermost mmctl --local token generate admin "ab-011-$(date +%H%M)"'
# → 86qus47tbfys9xehf4x5yxzuyr: ab-011-0129

ssh kraken '/tmp/mm-post-admin.sh 86qus47tbfys9xehf4x5yxzuyr 7bjw4g7o1jdsbmjbqd649xxgaw \
  "@balda вызови инструмент executor_health и покажи результат"'
# → posted id=hg6f1tgat7n53m345smertii5h user=cbwsorzwoigbtrdnteugnx4aqo

Результат: 0.1.11 падает ТЕМ ЖЕ error'ом. Лог docker logs balda-mm --since 5m:

{"level":"info","component":"balda.handlersfx.mattermost",…,"settlement_stage":"accepted",…}
{"level":"error","component":"balda.turn_dispatcher",
 "error":"agent run: duplicate tool: \"balda.control.shutdown\"",…,"message":"turn task failed"}
{"level":"warn","component":"balda.execution_bus","delivery_attempt":1,…,"settlement_class":"retrying:unclassified"}

📌 Авторизация прошла корректно (settlement_stage: accepted, post_user_id = Alex) — новый метод постинга (§17.1) работает, пост не отбит. До падения доходит именно ход агента.

Выводы (durable):

Утверждение Статус
duplicate tool вносится вторым раундом форка ❌ опровергнуто — 0.1.11 падает так же
На 0.1.11 инструменты «не пакались вообще» ❌ опровергнуто — паковка идёт до первого ответа модели
Откат на 0.1.11 — рабочее состояние ❌ ложно — на 0.1.11 инструменты вызывать НЕЛЬЗЯ, ход падает
Баг существует независимо от версии образа ✅ подтверждено

🔴 Следствие для стратегии: держать 0.1.11 «для отката» как рабочий вариант нельзя — он падает на любом явном вызове инструмента. Это не безопасная точка отката, а сломанное состояние. Единственный путь — фикс двойной паковки (§18) и 0.1.13.

Метод воспроизведения (1 команда, без Alex и без GUI):

TOK=$(ssh kraken 'docker exec mattermost mmctl --local token generate admin "ab-$(date +%s)"' | awk '{print $1}')
ssh kraken "/tmp/mm-post-admin.sh $TOK 7bjw4g7o1jdsbmjbqd649xxgaw '@balda вызови инструмент executor_health'"
sleep 45
ssh kraken 'docker logs balda-mm --since 3m 2>&1 | grep -E "duplicate tool|turn task failed" | tail -3'

📌 Пост обязан содержать явное требование вызвать инструмент — на обычное «скажи одно слово» Балда отвечает текстом, инструменты не пакуются, и баг не проявляется (что и породило ложный вывод «на 0.1.11 всё хорошо»).

⚠️ Побочный эффект откатов: жёсткое убийство контейнера оставляет незакрытый state.db-wal, и следующий старт на 0.1.11 идёт ~4.5 мин вместо ~1m11s (переигрывание WAL). Не считать это зависанием: docker logs | wc -l растёт рывками, процесс в do_epoll_wait — живой. Признак настоящего зависания — рост лога остановился и нет потока в epoll.

16.5 Побочный результат: чистка docker на Mac

docker system df до: образы 23.23 ГБ (освобождаемо 20.14), build cache 5.91 ГБ. Удалено: 10 тегов balda-mm:0.1.0…0.1.9 + docker builder prune --keep-storage 1GB. Стало: образы 18.83 ГБ, cache 2.05 ГБ. Оставлены для отката: 0.1.10, 0.1.11, 0.1.12, balda:mm-classifier, balda-executor:0.1.0, balda-executor-mcp:0.1.0+0.2.0. Тома не тронуты.

📌 Оценка «~8 ГБ за старые теги» была завышена в 10 раз: 10 тегов balda-mm указывали на общие слои, реально освободилось ~0.8 ГБ. Основной вес сидел в build cache (3.86 ГБ) и в balda-executor:0.1.0 (2.48 ГБ). docker system prune -a не применять — снесёт образы для отката и тома с данными.


17. 🔴 Как постить в qentra:balda-test (без Alex и без GUI)

Приёмка обязана идти от УЧЁТКИ ЗАКАЗЧИКА. Полный разбор — в personal/projects/balda/balda-kraken-mm-deploy (блок капсом в начале дока). Здесь — рабочий рецепт.

17.1 От владельца (admin) — правильная приёмка

# токен создаётся ОДИН раз и показывается один раз; --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 — только проверка конвейера, НЕ приёмка

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.

Пароль testuser был потерян при компакции контекста. session_search вернул ноль. Ответ дал запрос по Zulip PostgreSQL:

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. Сказано «из базы» → сразу sqlite3 ~/Documents/zulip_message_history.sqlite. Скилл: message-history-search (ранее zulip-db-forensics, снесён 2026-09-29 вместе с Zulip). Тот же урок — в personal/projects/balda/balda-kraken-mm-deploy.

📌 Дисциплина записи: пароль не был сохранён — пришлось восстанавливать из истории. Команды, которыми делается приёмка, сохранять в док вместе с параметрами.


18. 🔴 Фикс двойной паковки — план (2026-09-27)

⚠️ Раздел сохранён как история решения. Диагноз здесь (Option A) ОШИБОЧЕН — см. §19 с настоящим корнем и настоящим фиксом. Option A правил openAIConstructor под гипотезу «двойная паковка через два слоя», которая опровергнута исполняемым тестом. Фикс в итоге сделан в hostedToolsets, а не в конструкторах.

Контекст: корень доказан (§16.2), A/B проведён (§16.4). Фикс не начат — правка кода в форке требует подтверждения Alex (правило: план → апрув → работа).

18.1 Где чинить и почему НЕ в Балде

Вариант Вердикт
Патч ADK base_flow.go ❌ невозможно без вендоринга — google.golang.org/adk/v2 v2.4.0 это модульная зависимость (go.mod:27), файлы в $GOMODCACHE, read-only
Патч internal/apps/balda/* ❌ Балда задаёт только MCPServerIDs (builder.go:296-303), Tools/Toolsets пустые — дублировать нечего, чинить нечего
Патч форка runtime-local/agentfactory/agentfactory.go ✅ единственная доступная точка — форк уже подключён через replace в go.mod, мы им владеем

18.2 Суть фикса (Option A)

toolProcessor (§16.2 шаг 1) сам расширяет toolsets в f.Tools и при этом ставит f.Tools != nil — после чего защита if f.Tools != nil { return } срабатывает и повторное расширение не происходит. Но toolsetPreprocess вызывается безусловно и пакует те же инструменты второй раз.

Решение: не отдавать MCP-toolsets одновременно и как Toolsets. Для openAIConstructor / aistudioConstructor:

  • Toolsets: передавать только req.Toolsets (без hostedToolsets(...)),
  • MCP-инструменты добавлять в Tools — тогда f.Tools != nil и toolsetPreprocess останется без MCP-toolsets, которые всё равно уже расширены в Tools.

Файл: runtime-local/agentfactory/agentfactory.go Функции: openAIConstructor (:635-658), aistudioConstructor (:660-685) — обе (симметричный дефект), плюс hostedToolsets (:360-384) как источник.

18.3 Порядок работ (после апрува)

  1. Правка agentfactory.go + тесты в agentfactory/agentfactory_test.go.
  2. Гейты: go build ./..., go test ./..., go tool golangci-lint run ./... (по AGENTS.md форка).
  3. Тест на регрессию обязателен: тест, который падает на текущем коде с duplicate tool и проходит после фикса.
  4. Сборка образа на Mac: docker buildx build --builder arm64builder --platform linux/arm64 -f Dockerfile.release --provenance=false --load -t balda-mm:0.1.13 .
  5. docker save → scp → docker load на Kraken → tools/mm-switch-image.sh → docker compose up -d --force-recreate.
  6. Приёмка (§16.4 метод): пост от владельца с явным вызовом инструмента → ждём executor_health в ответе и отсутствие duplicate tool в логе.
  7. Обновить §0 (6h → ✅) и Фазу 3 в personal/projects/balda/balda-kraken-mm-deploy.

18.4 🔴 0.1.11 — НЕ точка отката

По итогам A/B (§16.4): на 0.1.11 любой ход с явным вызовом инструмента падает с тем же duplicate tool. Держать его как «безопасный откат» нельзя. Образы для отката: 0.1.10 (отвечает текстом, инструменты мертвы) — единственный, где бот отвечает.

18.5 Открытый вопрос Alex

Правку runtime-local/agentfactory/agentfactory.go (Option A) писать сейчас?


19. ✅🔴 НАСТОЯЩИЙ КОРЕНЬ и ФИКС — два тулсета на один эндпоинт (2026-09-27)

Сессия после §18. Alex: «Чё за тупые вопросы опять?! Чини блядь!!» → писать код без повторных апрувов. Диагноз §16.2/§18 опровергнут, найден истинный корень, фикс написан.

19.1 Корень — Balda регистрирует bundled-сервер ДВАЖДЫ на одном URL

internal/apps/balda/internalmcp/manager.go использует один и тот же адрес в двух местах:

// :206 — база для session-scoped брокера (URL эндпоинта + ?token)
m.contextBroker.SetBaseURL("http://" + res.Addr + baldaRoute)
// :213 — регистрация bundled-сервера
m.registry.Set(bundledBaldaServerID, agentconfig.MCPServerConfig{
    Type: agentconfig.MCPServerTypeHTTP,
    URL:  fmt.Sprintf("http://%s%s", res.Addr, baldaRoute),
})

sessionmemorymcp/broker.go:

  • Bind() (:111) отдаёт ID: "balda-session-memory-"+token, а URL (через contextURL, :216-225) — тот же эндпоинт плюс ?<contextQueryParameter>=<token>.
  • Wrap() (:118-153) — тот же handler, инжектит trusted-заголовки по токену.

internal/apps/balda/agent/runtime_manager.go:344-345 регистрирует привязку и добавляет её ID в extraMCPServerIDs:

registry.Set(binding.ID, binding.Config)
extraMCPServerIDs = append(extraMCPServerIDs, binding.ID)

Следствие: hostedToolsets строит два MCP-тулсета на один и тот же сервер. Оба отдают одинаковый список инструментов (включая balda.control.shutdown). toolProcessor складывает оба набора в один плоский f.Tools, toolPreprocess пакует первый → OK, второй → PackTool (toolutils.go:47-48) видит занятое имя → duplicate tool.

Факт Значение
Триггер session_memory.enabled: true (создаёт привязку) и ход с явным вызовом инструмента
Почему на 0.1.11 «тихо» Балда отвечала текстом → инструменты не пакались → баг не всплывал
Почему A/B дал падение Пост содержал явное требование вызвать инструмент
Связь с форком Никакой — дефект регистрации в Балде, существовал всегда

19.2 Что опровергнуто исполняемым тестом (не возвращаться)

Версия Проверка Итог
§16.2: toolsetPreprocess пакует MCP-тулсеты второй раз TestMcpToolsetImplementsRequestProcessor → mcptoolset.set не реализует ProcessRequest ❌ невозможно
§16.2: «дефект архитектуры ADK» Защита if f.Tools != nil { return } работает as designed ❌ контракт нарушила регистрация
§18 Option A: править openAIConstructor/aistudioConstructor Гипотеза двойной паковки ложна ❌ фикс не там
hostedToolsets дублирует resolvedMCP resolvedMCP — map по id, ключ уникален ❌ дубля нет (но два разных ключа на один URL — да)

📌 Метод, который дал ответ: не чтение ADK, а исполняемый тест на присутствие интерфейса. go test ./agentfactory/ -run TestMcpToolsetImplementsRequestProcessor -v → MCP toolset "mcp_tool_set" does NOT implement ProcessRequest. Час чтения исходников не дал того, что дал один тест.

19.3 Фикс — дедуп тулсетов по эндпоинту (написан 2026-09-27)

Файл: runtime-local/agentfactory/agentfactory.go, функция hostedToolsets.

Идея: два конфига, адресующие один host+path, — это один и тот же сервер с точки зрения пространства имён инструментов. Различие в query-строке (контекст-токен Балды) не должно делать их разными.

// схлопываем эндпоинты, отличающиеся только query
keeperOf := make(map[string]string, len(ids))
skip := make(map[string]bool, len(ids))
for _, id := range ids {
    key := mcpEndpointKey(resolvedMCP[id])
    if key == "" { continue }
    keeper, ok := keeperOf[key]
    if !ok { keeperOf[key] = id; continue }
    // оставляем БОГАТУЮ запись: scoped-URL несёт контекст-токен → супернабор
    if mcpURLIsScoped(resolvedMCP[id]) && !mcpURLIsScoped(resolvedMCP[keeper]) {
        skip[keeper] = true; keeperOf[key] = id; continue
    }
    skip[id] = true
}

Новые хелперы:

  • mcpEndpointKey(cfg) — url.Parse, схема+host+path, RawQuery и Fragment обнуляются.
  • mcpURLIsScoped(cfg) — есть ли query (признак session-scoped привязки Балды).

🔴 Критично, какую запись оставлять. Наивный вариант «первый по алфавиту» оставляет balda и выбрасывает scoped-привязку — фикс перестаёт падать, но ломает session memory (теряется контекст-токен). Поэтому keeper — запись со scoped-URL: она строгий супернабор (инжектит заголовки, а без токена Wrap просто пропускает запрос через next.ServeHTTP).

19.4 Тесты (написаны)

runtime-local/agentfactory/duplicate_toolset_test.go:

Тест Что проверяет
TestHostedToolsetsCollapsesScopedEndpointDuplicate 2 записи на один URL → 1 тулсет
TestHostedToolsetsKeepsDistinctEndpoints balda + executor-mcp + obsidian → 3 тулсета (защита от сверх-схлопывания)
TestMCPEndpointKey ?ctx=tok, ?a=1&b=2 → ключ без query
TestMcpToolsetImplementsRequestProcessor фиксирует факт: mcptoolset.set не имеет ProcessRequest

19.5 ✅ ПРИЁМКА ПРОЙДЕНА — balda-mm:0.1.13 (2026-09-27)

Гейты: go build ./... ✅ · go vet ./... ✅ · go test ./... форка ✅ · go test ./agentfactory/ -run 'Toolset|MCPEndpoint|McpToolset' -v → 6/6 PASS.

Деплой: compose image: balda-mm:0.1.13 (бэкап docker-compose.yml.bak-013-<ts>), docker compose up -d --force-recreate, старт OK (Balda bot started).

Приёмка от владельца (@balda вызови executor_health и покажи результат):

1790454574509 | cbwsorzwoigb | @balda вызови executor_health и покажи результат
1790454694404 | edxrgupoxiyg | **executor_health:** `ok`

Метрики нового контейнера:

Проверка Значение
duplicate tool: (ошибка) 0 ✅
turn task failed / command deadlettered 0 ✅
turn_complete / final_response true / true ✅
function_response_part_count 1 — инструмент реально вызван ✅
delivery settled (agent_reply) success ✅
Строка фикса в логе [mcp] skipping duplicate toolset "balda": endpoint already served by "balda-session-memory-<token>" ✅

⚠️ Питфолл своей же проверки: grep -c "duplicate tool" даёт 1, потому что моя строка лога — «skipping duplicate toolset». Искать ошибку надо строго как grep -c "duplicate tool:" (с двоеточием, как в формате PackTool). Иначе ложная тревога на ровном месте.

Остаток: §0 строка 6e (форк) — закрыта ранее; 6f/6g/6h/7 — ✅.

Гейты (прогнано 2026-09-27): go build ./... → OK, go vet ./... → OK, go test ./... в форке → все зелёные. Целевой прогон: go test ./agentfactory/ -run 'Toolset|MCPEndpoint|McpToolset' -v → 6/6 PASS, в логе видно требуемое поведение: [mcp] skipping duplicate toolset "balda": endpoint already served by "balda-session-memory-abc123" — выживает именно scoped-запись.

19.6 Остаток работ (незначительный)

# Пункт Почему не срочно
1 Удалить с Kraken диагностический образ balda-mm:0.1.13-diag Он уже не используется: compose указывает на 0.1.13. Занимает ~79 МБ
2 Почистить старые теги balda-mm:0.1.0…0.1.9 Оставлены ранее осознанно для отката; 0.1.10-0.1.13 трогать не нужно
3 Завести Access Application на agents-bridge.qentra.top Отдельный пункт §9.5, к этому дефекту не относится

⛔ Не удалять без явного подтверждения Alex (правило: никаких удалений без апрува).

⚠️ Урок про перегенерацию образа: сборка, запущенная до последней правки кода, даёт устаревший бинарник. В этой сессии так и случилось: build стартовал, затем правки в mcpEndpointKey (добавлен sse), пришлось убивать процесс и пересобирать. Порядок: сначала все правки → финальный go build/go test → и только потом docker build.

🔴 Проверка чувствительности теста: отключение keeper-предпочтения (if false && …) тест не ломает — он проверял только количество. Поэтому добавлен selectScopedOnly: теперь тест падает, если выживает не scoped-запись. Слабый тест = ложная уверенность.

Дополнительно: mcpEndpointKey/mcpURLIsScoped распространены на sse (обе ветки switch по MCPServerTypeHTTP, MCPServerTypeSSE); stdio ключа не даёт и не схлопывается никогда. Тесты TestMCPEndpointKeySSE, TestMCPEndpointKeyStdioNeverCollapses.

⚠️ На Kraken сейчас стоит диагностический образ 0.1.13-diag (с временными fmt.Printf), не 0.1.11. Бэкап compose: docker-compose.yml.bak-diag-<ts>. Диагностические логи в hostedagent/openai.go и internal/apps/balda/agent/builder.go откачены (в дереве чисто).

Остаток:

  1. cd ~/Developer/balda/runtime-local && go test ./agentfactory/ -run 'Toolset|MCPEndpoint' -v
  2. Собрать balda-mm:0.1.13 (без -diag), залить, docker compose up -d --force-recreate.
  3. Приёмка от владельца с явным вызовом инструмента → ждём ответ и отсутствие duplicate tool в логе.
  4. Вернуть compose на актуальный тег (сейчас 0.1.13-diag).
  5. §0: 6h → ✅; Фаза 3 в personal/projects/balda/balda-kraken-mm-deploy.

19.6 🔴 Kraken перегружен — docker-операции идут минутами

Во время сессии load average поднимался до 8.03 при 4 ядрах (нет одного CPU-хога — это I/O-wait на HDD). Симптомы, которые не являются дефектом Балды:

Симптом Объяснение
docker compose up -d не отвечает >5 мин хост thrashing, не зависание
docker rm -f → removal … already in progress команда реально идёт, просто медленно
Контейнер в статусе Dead несколько минут штатное промежуточное состояние при убитом recreate
Старт Балды ~4.5 мин вместо ~1м11с переигрывание state.db-wal (§16.4)

📌 Правило: на Kraken не повторять docker-команды «потому что долго» — это удваивает нагрузку. Проверять uptime и ps -eo pcpu,pid,comm --sort=-pcpu.

⚠️ Прерванный docker compose up -d --force-recreate оставляет мусор: полу-созданный контейнер <hash>_balda-mm + старый в Dead. Лечится docker rm -f на оба (медленно).


20. 🔴 Проверка 2026-09-28: единственный блокер — CLI не залогинен

Сессия началась с вопроса Alex: «Что далее чтобы заставить все это работать?». Проведён аудит всей цепочки живыми вызовами (не по докам). Итог: Фаза 4 завершена везде, кроме одного звена — авторизации CLI внутри контейнера-исполнителя.

20.1 Что проверено живыми вызовами и РАБОТАЕТ

Узел Проверка Результат
Балда + бот MM на Kraken docker ps, docker-compose.yml balda-mm:0.1.13 Up 19h ✅
Логи Балды docker logs balda-mm --since 6h | grep -c error 0 ошибок ✅
duplicate tool не вернулся grep по логам чисто ✅
MCP-сервер исполнителя initialize из контейнера mattermost HTTP 200, serverInfo.name=balda-executor-mcp ✅
Мост на Mac curl 127.0.0.1:8790/healthz status: ok, container_running: true ✅
Cloudflare Tunnel curl https://agents-bridge.qentra.top/healthz 200 с обеих сторон ✅
Контейнер-исполнитель docker ps balda-executor:0.1.0 Up 40h (healthy) ✅
Тулчейн в контейнере docker exec … which claude + codex + node + git на месте ✅
PR #8 gh pr view 8 OPEN, MERGEABLE, reviewDecision: "" — внешний блокер, не наш ✅

20.2 Что СЛОМАНО — ровно одна вещь

Задача через мост (POST /task, provider: claude):

cd ~/Developer/agents-bridge
SECRET=***  # из .env, поле BRIDGE_SECRET
curl -s -X POST http://127.0.0.1:8790/task \
  -H "Content-Type: application/json" -H "X-Bridge-Secret: $SECRET" \
  -H "Idempotency-Key: probe-auth-$(date +%s)" \
  -d '{"prompt":"Reply with exactly: BRIDGE_OK"}'
# → {"task_id":"48fcd4a7d0a44a0c","status":"queued",…}

Результат задачи (GET /task/<id> с заголовком X-Bridge-Secret):

{ "status": "failed", "exit_code": 1, "duration_s": 0.8,
  "stdout": "Not logged in · Please run /login\n", "stderr": "" }

📌 Конвейер рабочий: задача дошла до CLI, CLI ответил, ответ вернулся через мост. Падение — внутри CLI, на проверке авторизации.

20.3 Доказательство, что логина нет НА ХОСТЕ (а не просто в контейнере)

docker exec balda-executor sh -c 'jq -c "keys" /root/.claude.json'
# → ["firstStartTime","firstStartVersion","hasResetAutoModeOptInForDefaultOffer",
#    "machineID","migrationVersion","opusProMigrationComplete","pluginUsage",
#    "seenNotifications","sonnet1m45MigrationComplete","userID"]

Это свежий стартовый файл: 9 служебных ключей, ключа oauthAccount нет. Никаких следов авторизации.

ls -la ~/cli-auth/claude/
# → backups/  projects/  sessions/          ← монтируется в /root/.claude
ls -la ~/cli-auth/claude/backups/
# → .claude.json.backup.1790423979201  (84 байта)
#   .claude.json.backup.1790435884422  (84 байта)

🔴 В хост-монтировании ~/cli-auth/claude/ НЕТ файла .claude.json. Лежат только backups/, projects/, sessions/. Тот файл в контейнере (692 байта, Sep 26 15:18) — это контейнерная копия, созданная самим CLI, а не проброшенный логин. Бэкапы по 84 байта — тоже не креды.

Корень: логин никогда не был посажен туда, откуда он переживает пересоздание контейнера. docker compose up -d пересоздаёт контейнер, монтирование отдаёт пустой набор файлов (только каталоги), CLI генерирует себе пустой .claude.json с нуля → каждый раз «Not logged in».

20.4 Контекст: как CLI вызывается

~/Developer/agents-bridge/executor.py:21-25:

PROVIDERS: dict[str, list[str]] = {
    "claude": ["claude", "-p", "--output-format", "text"],
    "codex": ["codex", "exec", "--skip-git-repo-check"],
}
# :41 — запуск происходит НЕ на хосте, а через docker exec в контейнер
return ["docker", "exec", "-w", WORKDIR, "@container", *base, prompt]

Значит авторизация нужна внутри контейнера, а не на Mac. WORKDIR = "/agents".

20.5 Два пути фикса (решение за Alex — ждёт ответа)

Путь A (рекомендован) — claude setup-token, токен в переменную окружения.

Почему лучше: долгоживущий токен не файл → пересоздание контейнера его не затирает. Документированный Anthropic путь для headless (см. §0.6 «Юридическая граница» — разрешён, не дыра).

  1. Alex на Mac: claude setup-token (открывает браузер, аккаунт подписки) → получает токен.
  2. Токен кладём в ~/Docker/balda-executor/.env как CLAUDE_CODE_OAUTH_TOKEN=… (.env — 0600, уже в env_file compose → в контейнер попадает автоматически).
  3. Проверка: задача через мост → ждём ответ модели вместо Not logged in.

⚠️ Питфолл записи в .env: write_file редактирует/калечит секреты (personal/tech/hermes-secret-redaction-bypass) — значение собирать на хосте скриптом, не литералом.

Путь B — залогиниться в контейнере, затем СКОПИРОВАТЬ креды на хост.

  1. Alex: docker exec -it balda-executor claude → /login.
  2. Я копирую (не перемещаю) ~/.claude.json и .credentials.json из контейнера в ~/cli-auth/claude/ с бэкапом существующего каталога.
  3. Проверка: docker compose up -d (пересоздание) → повторить задачу → логин должен выжить.

Почему B хуже: зависит от того, что CLI держит всё в файлах и что структура не поменяется; setup-token — официально поддерживаемый для headless путь.

Не делать: логиниться на голом Mac в ~/.claude и ждать, что это проедет в контейнер — монтируется ~/cli-auth/claude, не ~/.claude. На хосте Mac логин вообще не участвует.

20.6 Что проверить ПОСЛЕ фикса (сквозная приёмка Фазы 4)

  1. POST /task через мост → реальный ответ модели, exit_code: 0.
  2. Пост от владельца в MM (@balda …) → Балда → executor_run → мост → CLI → ответ в чат. Метод постинга от владельца — §17.1 (admin PAT через mmctl --local token generate).
  3. codex тоже проверить (второй провайдер в том же образе).
  4. Обновить §0: строка 8 → ✅, Фаза 4 → закрыта; Фаза 5 (связка и приёмка) в personal/projects/balda/balda-mm-delegation-plan.

Связанные заметки