[2026-09-26] eagle: personal/plans/balda-mm-phase4-agents.md personal/plans/tagwriter-container.md personal/projects/balda/balda-valera.md personal/tech/balda-go-fx-pitfalls.md personal/tech/music-tags-repair-truenas.md

This commit is contained in:
Alexey Martemyanov committed 2026-09-26 23:59:25 +06:00
1 parent 593b867467
commit 84fee3eea2
5 files changed
+507 -16

No files matched your search

+192
View File
@@ -20,6 +20,17 @@
> `balda.session_memory` vs `balda.memory`, MCP через секцию провайдера.
> §«Сброс падающих тестов» — сначала `git stash`, потом слово «регрессия»;
> `buildx` без `--load` не кладёт образ в `docker images`.
> 🔴 **Добавлено 2026-09-26 (вторая итерация той же сессии):**
> §«Подключение MCP ≠ ИСПОЛНЕНИЕ инструментов» — `type: openai` подключает ID серверов,
> но инструменты исполняет только **ACP-провайдер** (`builder.go:479-518`); лимиты
> reconciler'а опровергнуты. §«Живая приёмка бота БЕЗ GUI» — `mmctl --local post list`
> как доказательство, и почему `post create` в local-режиме невозможен.
> 🔴🔴 **Добавлено 2026-09-26 (третья итерация):** §«НАСТОЯЩИЙ корень: в `runtime v2`
> вырезан `tool_calls`» — апстрим переписал `hostedagent/openai.go` и **потерял** наш
> фикс из `norma-local` (`91c2c0a`). Полный разбор 4 блоков патча + 2 питфолла DeepSeek
> (двойная сериализация аргументов, ключ `output`) + регресс v2 «ответ без текста».
> 📌 Урок метода: **прежде чем объявлять ограничение апстрима — свериться со своим
> старым патчем.** Ключ Alex: «Это не та же проблема, что мы решали в форке?»
> Зеркало секции скилла `devops/docker-balda-service`, добавленной 2026-09-26.
> Источник: работа над Mattermost-адаптером Балды (Фаза 2) + деплой на Kraken (Фаза 3).
> Проектные доки: [[personal/projects/balda/balda-mattermost-connector]],
@@ -216,6 +227,187 @@ warn … session-memory capability retry limit exhausted
---
## 🔴🔴 Подключение MCP ≠ ИСПОЛНЕНИЕ инструментов: нужен ACP-провайдер
Найдено 2026-09-26. Класс ошибки: **два разных слоя, которые легко слить в один вывод.**
| Слой | Что происходит | Зависит от типа провайдера? |
|---|---|---|
| **Подключение** | ID серверов попадают в сессию, видны в стартовом сообщении (`MCP: balda, executor-mcp, obsidian`) | ❌ нет — список собирается из конфига |
| **Исполнение** | модель реально вызывает инструменты | 🔴 **да — только ACP-провайдеры** |
**Симптом неисполнения:** сессия перечисляет серверы, но бот на вопрос отвечает
«доступно только `balda.memory`» — и то же говорит про **свой** `balda.workspace`.
То есть недоступен **ни один** MCP-инструмент, включая встроенный. Это маркер слоя,
а не отдельного сервера: если не работает и bundled — дело не в конфиге серверов.
**Причина в коде** — `internal/apps/balda/agent/builder.go`:
```go
// :518 — bundled `balda` добавляется КОДОМ, не конфигом
MCPServers: mergeMCPServerIDsWithBase([]string{"balda"}, agentCfg.MCPServers, nil),
// :479-500 — GetAgentMetadata знает 10 ACP-типов
switch strings.TrimSpace(agentCfg.Type) {
case agentconfig.AgentTypeGenericACP: acp = agentCfg.GenericACP
case agentconfig.AgentTypeGeminiACP: acp = agentCfg.GeminiACP
case agentconfig.AgentTypeCodexACP: acp = agentCfg.CodexACP
case agentconfig.AgentTypeOpenCodeACP: acp = agentCfg.OpenCodeACP
case agentconfig.AgentTypeCopilotACP: acp = agentCfg.CopilotACP
case agentconfig.AgentTypeClaudeCodeACP,
agentconfig.AgentTypeClaudeACP: acp = agentCfg.ClaudeCodeACP
case agentconfig.AgentTypeGrokACP: acp = agentCfg.GrokACP
case agentconfig.AgentTypeRegistryACP: acp = agentCfg.RegistryACP
case agentconfig.AgentTypeAgyACP: acp = agentCfg.AgyACP
case agentconfig.AgentTypeAntigravityACP: acp = agentCfg.AntigravityACP
}
// :504-511 — разделение по классу
switch {
case acp != nil: // ACP-рантайм: инструменты исполняются
model = strings.TrimSpace(acp.Model)
case agentCfg.OpenAI != nil: // 🔴 только модель, БЕЗ ACP
model = strings.TrimSpace(agentCfg.OpenAI.Model)
}
```
📌 **Правило:** `type: openai` (и любой чистый chat-completions провайдер) подключает
ID серверов, но **не исполняет** их инструменты. Для рабочих MCP-инструментов есть
два пути: **ACP-провайдер** (`claude_code_acp`, `codex_acp`, `gemini_acp`, `opencode_acp`,
`copilot_acp`, `grok_acp`, `registry_acp`, `agy_acp`, `antigravity_acp`, `generic_acp`)
**или** починить tool_calls в `hostedagent/openai.go` — см. следующий раздел.
⚠️ **Как не запутаться (мой промах):** сначала я записал «`openai` не умеет MCP» как
**ложный след**, потом снова пришёл к нему же. Оба раза прав был частично — на **разных
слоях**. Писать надо явно: «ID подключаются у любого типа; инструменты исполняются
только у ACP». Формулировка без указания слоя гарантирует путаницу.
📌 **Опровергнутая гипотеза (не тратить время):** лимиты reconciler'а.
`mcpruntime/reconciler.go:97` — defaults `MaxServers: 32, MaxTools: 256,
MaxToolMetadataBytes: 256<<10`. Три сервера и три инструмента в них укладываются
с большим запасом; серверы **не** отбрасываются лимитом.
---
## 🔴🔴 НАСТОЯЩИЙ корень: в `runtime v2` вырезан `tool_calls` для openai-провайдера
Найдено 2026-09-26. **Alex опознал проблему раньше меня:** «Это не та же проблема,
которую мы решали в mallexxx-форке старой балды?» — да, ровно та же. Я весь вечер
подходил к выводу «openai не умеет инструменты» и не догадался свериться со **своим
же старым патчем** в `~/Developer/norma-local`.
📌 **Урок метода:** прежде чем объявлять ограничение апстрима («тип провайдера не умеет»),
проверить, не закрывали ли мы ЭТУ дыру раньше своим патчем. Ограничение и незакрытая
дыра выглядят одинаково, но лечатся по-разному.
### Что было в патче (закрыт 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` |
| История | `contentToOpenAI()` — конвертация `FunctionCall`/`FunctionResponse` в сообщения (`role: assistant` с `tool_calls`, `role: tool` с `tool_call_id`) |
| Цикл | `TurnComplete = !hasToolCalls` — ADK получает второй раунд на исполнение |
**Питфолл DeepSeek — двойная сериализация аргументов.** Модель отдаёт
`function.arguments` как **JSON-строку**, а не объект. Разбор в два шага:
```go
var argsStr string
if err := json.Unmarshal(tc.Function.Arguments, &argsStr); err == nil {
if err2 := json.Unmarshal([]byte(argsStr), &args); err2 != nil {
args = map[string]any{"raw": argsStr}
}
} else if err2 := json.Unmarshal(tc.Function.Arguments, &args); err2 != nil {
args = map[string]any{"raw": string(tc.Function.Arguments)}
}
```
Без этого Obsidian MCP падал с `Cannot read properties of undefined (reading 'replace')`.
**Питфолл ключа ответа.** `FunctionResponse.Response` приходит в разных формах —
проверять по очереди: `result`, `response`, `text`, `error`, `content`, **`output`**
(последний — специфика Obsidian MCP), иначе `json.Marshal` всего словаря.
### Что в `runtime v2.0.11` — вырезано НОЛЬ из пяти
`$(go env GOMODCACHE)/github.com/normahq/runtime/v2@v2.0.11/hostedagent/openai.go` —
**246 строк** (против 490 у пропатченного):
| Что | Статус в v2 |
|---|---|
| `Tools` в запросе | ❌ `openAIToolsFromConfig` отсутствует целиком |
| `tool_calls` в ответе | ❌ `parseChatResponse` знает только `Content` |
| История tool-результатов | ❌ `openAIMessagesFromRequest:204-208` режет всё, кроме текста |
| `TurnComplete` | ❌ жёстко `true` (:107) |
🔴 **Отдельный регресс v2 — падение на «ответ без текста»** (:180-182):
```go
if len(parsed.Choices) == 0 || strings.TrimSpace(parsed.Choices[0].Message.Content) == "" {
return nil, fmt.Errorf("openai response missing content")
}
```
Ответ **только** с `tool_calls` (без текста) валиден для OpenAI API, но v2 считает
его ошибкой. Чинить **первым** — иначе второй раунд ADK упадёт до вызова инструмента.
📌 **Версии ADK при переносе:** в `norma-local` — `adk` v1 (`google.golang.org/adk/model`),
в `runtime v2` — `adk/v2`. Типы `genai.Tool` / `FunctionDeclaration` сверять по месту.
📌 **Модуль кэша read-only:** патчить `go/pkg/mod/...` нельзя. Путь — форк + `replace`
в `go.mod` потребителя (как делали с `norma-local`).
---
## 🔴 Живая приёмка бота в Mattermost БЕЗ GUI и БЕЗ участия заказчика
Метод 2026-09-26. Alex отказался быть «руками» для проверки («ты ведь даже не поверил
ожил ли балда вообще»). Агент обязан проверять сам.
### Что НЕ работает
| Путь | Ошибка | Причина |
|---|---|---|
| `mmctl post create <team:channel> --message '…'` | `creating posts is not supported in local mode` | `--local` ходит через unix-сокет: **чтение разрешено, запись от имени человека — нет** |
| `mmctl post create --channel <id>` | `unknown flag: --channel` | цель — **позиционный** аргумент формата `team:channel` |
| `docker exec mattermost curl -X POST …/users/login -d '{…}'` | `Syntax error: Unterminated quoted string` | вложенные кавычки JSON ломаются в ssh+sh. Писать **скрипт в файл**, не инлайн (правило Alex) |
### Что работает
```bash
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
```
`post list` печатает ленту с автором (`[admin]` / `[balda]`) — это **однозначное
доказательство**, что бот отвечает. Заменяет «видно в UI», когда GUI недоступен.
⚠️ Для генерации **человеческого** поста `--local` не годится — нужен токен админа
через REST-логин. Либо принять, что проверка идёт по уже существующей ленте.
### Проверка самих MCP-серверов из docker-сети
```bash
# executor-mcp: initialize → получаем mcp-session-id → 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:{}}
# obsidian-mcp (SSE): жив, если отдаёт event: endpoint
docker exec mattermost curl -s -m 5 -i "http://obsidian-mcp:3101/sse"
# → 200, Content-Type: text/event-stream, event: endpoint / data: /messages?sessionId=…
```
📌 **`curl` внутри контейнера `mattermost` предпочтительнее `docker run curlimages/curl`** —
последний тянет образ из сети и легко уходит в таймаут на медленном канале.
📌 Редиректы `-o file` внутри `docker exec` могут не оставить файла на хосте — читать stdout.
---
## 🔴 Сброс падающих тестов: сначала `git stash`, потом слово «регрессия»
Метод 2026-09-26. `go test -race ./...` на ветке `feat/mattermost-channel` даёт 7 падений
+53 -1
View File
@@ -78,9 +78,61 @@ related:
> ⏭ **Дальше:** применение тегов через контейнер `tagwriter` — [[personal/plans/tagwriter-container]].
> ✅ Контейнер собран, **dry-run прошёл (MISSING 0, к обработке 7041)**.
> ✅ **Бэкап выполнен: 7041 файл, 42 ГБ → `/mnt/RED_2TB/storage/Music/backup/`** (файлов в backup 7041, сходится с планом).
> Осталось: `--apply` → `--readback`.
> ✅ **APPLY ВЫПОЛНЕН: `APPLY: обновлено 7041, ошибок 0`** (~17 мин, запуск в фоне).
> ✅ **READ-BACK: не найдено на диске 0.**
> Блокер прав на `/mnt/RED_2TB/docker/` решён: каталог создан **из контейнера** (`docker run -u 0 -v /mnt/RED_2TB/docker:/docker alpine mkdir` + `chown 950:950`).
>
> ### 📊 НЕЗАВИСИМАЯ СВЕРКА `verify_tags.py` (2026-09-26) — 96.3%
>
> | Метрика | Значение |
> |---|---:|
> | Строк в плане | 7905 |
> | **Совпало TIT2+TPE1+TALB+TRCK** | **7610 (96.3%)** |
> | MISSING (не найден / не MP3) | 83 |
> | MISMATCH (тег ≠ план) | 212 |
> | Файлов на диске (индекс NFC) | 7822 |
>
> **MISSING 83:** из видимых 10 — **9 `.wma` + 1 `.ogg`** (mutagen пишет только ID3/MP3 →
> ожидаемо не обрабатываются). Остальные 73 **не разобраны** — скрипт печатал только первые 10.
>
> **MISMATCH 212:** из видимых 30 — `TPE1` 16, `TRCK` 11, `TIT2` 9, `TALB` 6. Классы:
> - **mojibake (3)** — CP1251 прочитан как UTF-8: `Ãðÿçíàÿ êðîâü` → `Грязная кровь`
> (`Сектор Газа/05 - Грязная кровь.mp3`, `07 - Черный вурдалак.mp3`, `НТЛ/САВЛО&LIL'DEN vs. NTL.mp3`).
> Это **битые исходные ID3**, не результат apply. ⚠️ mutagen их не перезаписал.
> - **регистр/транслит** — `Ace of Base` / `Nautilus Pompilius` / `Fleur` / `Vanessa-Mae` / `Иванушки Int`.
> - **TRCK пустой или чужой** — `None`, `14`→`1`, `2`→`None`, `0`→`24`.
> - **TALB с мусором** — `Лучшие песни. Часть 2` (план: `Лучшие песни`).
> - **TIT2 усечён/с артистом внутри** — `Shop` vs `Candy Shop`; `Sander Kleinenberg - The Fruit…`.
>
> ⚠️ **Статус разбора: НЕПОЛНЫЙ.** Лог обрезан (скрипт печатал `первые 10`/`первые 30`).
> Фикс (печать всех) готов локально в `/tmp/verify_tags.py`, **но НЕ залит** — апрув истёк дважды.
> Причины 212 MISMATCH **не установлены**: возможны правки после apply, дубли имён файлов,
> битые исходные теги.
>
> ### 🔴 ПИТФОЛЛ: скрипт в каталоге проекта НЕ попадает в образ
>
> `docker compose run tagwriter /app/verify_tags.py` → `can't open file: No such file or directory`.
> `/app` — это **образ**, а не каталог проекта. Новые файлы не видны, пока не смонтируешь явно:
> `-v /mnt/RED_2TB/docker/tagwriter/verify_tags.py:/app/verify_tags.py:ro --entrypoint python`.
>
> ### 🔴 ПИТФОЛЛ: маппинг префикса путей нужен и в скрипте сверки
>
> Первый прогон `verify_tags.py` дал **7905 ложных MISSING**: скрипт искал `/storage/Music/…`,
> а в контейнере это `/music/…`. **Правило: любой скрипт внутри контейнера обязан мапить
> `/storage/Music/` → `/music/`**, иначе сравнение путей всегда падает.
>
> ### 🔴 ПИТФОЛЛ: логи apply/readback буферизуются
>
> При запуске через ssh (non-tty) stdout Python буферизуется — лог **пуст**, пока процесс идёт,
> и появляется целиком в конце. Пустой лог при `running` ≠ ошибка. Прогресс смотреть по
> `docker ps` (uptime контейнера) или `du -sh` (для бэкапа).
>
> ⚠️ **Пул:** после бэкапа `df` показал `RED_2TB` = 935G (было 976G) — ZFS-снапшоты. Свободно 919 ГБ.
> Бэкап (`/music/backup/`, 7041 файл) удалять **только после приёмки в Jellyfin**.
>
> ⏭ **Осталось:** (1) получить полный список 83 MISSING + 212 MISMATCH → разобрать причины;
> (2) приёмка в UI Jellyfin (глазами Alex); (3) после приёмки — удалить бэкап.
>
> ### 🔴 ПИТФОЛЛ: sentinel-группа собирается по КОРНЮ сборника, НЕ по ближайшей папке
>
> Первая версия правки группировала пустой `artist` по `parts[-2]` →