# Balda — Go/fx pitfalls и метод правок (зеркало скилла) > Создано 2026-09-26. Обновлено 2026-09-26 (сессия реализации MM-хендлера, полный проход; > 🔴 **статус приёмки ИСПРАВЛЕН** — см. §«Ложная приёмка» и §«Чинить в коде, а не в данных»). > Добавлены: **`bad_seq` — настоящая причина тишины WS** (§«WS-фрейм без seq»), > **`optional:"true"` скрывает отсутствующую зависимость** (§«Дефект 5»), > **`canonicalSubject` и неизвестный транспорт** (§«auth: subject-based»), > **`/start` — тупик онбординга при проверке доступа** (§«Дефект 6»), > **`base_url` в YAML мёртв — только `OPENAI_BASE_URL`** (§«Провайдер: …»), > **`FROM scratch` без CA** (§«scratch и TLS»), > **`principal` с префиксом в БД** (§«Дефект 7»), > **`state.db` читать вместе с WAL** (§«Метод: чтение sqlite-состояния»), > **🔴 ложная приёмка от чужого аккаунта** (§«Ложная приёмка»), > **🔴 двойной префикс жив в коде, а не только в БД** (§«Чинить в коде»), > **🔴 замкнутый круг привязки при занятом binding** (§«Дефект 8»). > 🔴 **Новое в этой сессии:** §«Mattermost Team Edition: что недоступно» — push невозможен. > Уточнён §«авторизация — ОДИН механизм»: челлендж НЕ был причиной №1, им был `bad_seq`. > 🔴 **Добавлено 2026-09-26 (сессия MCP-фикса):** §«Scope-классификатор нового транспорта» — > написать мало, надо зарегистрировать в ДВУХ картах (`app.go` + `preflight.go`); смежное — > `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. > 🔴🔴🔴 **Добавлено 2026-09-27 (четвёртая итерация):** §«Патч tool_calls ОБНАЖИЛ двойную > паковку инструментов» — почему `duplicate tool` вернулся на `0.1.12`, хотя конфиг чист. > Ключевой инсайт: апстримный `TurnComplete: true` **маскировал** баг — агент не доходил > до паковки инструментов. Как только патч включил второй раунд, ADK-механика проявилась. > Плюс метод: §«Доказать, что код патча в образе» (распаковать OCI-слои, грепнуть бинарник). > 🔴 **Добавлено 2026-09-27 (PR фикса MCP-тулинга в `runtime`):** > §«Два линтера спорят на одной строке теста» — `staticcheck ST1023` vs `unconvert`; > решение «убрать конструкцию, а не выбирать сторону» + побочный неиспользуемый импорт. > Там же проверенные гейты перед PR в `runtime` и питфолл таймаута `go test -race ./...` (>600 с). > 🔴🔴🔴 **Добавлено 2026-09-28 (правки по ревью PR #8) — четыре новых раздела:** > §«Новый транспорт = ЗЕРКАЛО Slack» — правило Alex, 5 расхождений без причины, таблица > «что брать по Slack» + ровно 2 допустимых отличия (верификация, форма ответа); > §«Slash-команды Mattermost: только HTTP» — WS их не видит, разделять «команда есть» и > «до неё есть вход»; §«Групповой DM (`G`): сессия по ЧАТУ» — каскад из 8 мест, отдельный > префикс `g:`; §«Регистрация в `ScopeResolver` — проверять в COMMIT, а не в рабочем дереве» — > `grep` по файлу врёт, `git show HEAD: | grep` — истина для PR. > Плюс: имена типов/префиксов сверять с `locator_codec.go`, а не выдумывать (декодер с > `"direct"` вместо `"dm"` молча ломает транспорт). > Источник: работа над Mattermost-адаптером Балды (Фаза 2) + деплой на Kraken (Фаза 3). > Проектные доки: [[personal/projects/balda/balda-mattermost-connector]], > [[personal/projects/balda/balda-kraken-mm-deploy]], > [[personal/plans/balda-mm-endtoend-plan]], > [[personal/projects/balda/balda-mm-delegation-plan]]. --- ## 🔴🔴 Ложная приёмка: сквозной путь от ЧУЖОГО аккаунта — это не приёмка **Самый дорогой урок сессии.** Я отчитался «фаза закрыта, приёмка пройдена», проверив сквозной путь от `testuser` — аккаунта, созданного мной же для тестов. Alex написал `@balda привет` — тишина, в логе `settlement_stage="unauthorized"`. **Причина:** владельцем бота был привязан `testuser`. Аккаунта Alex в `balda_user_bindings` **не было вообще**. Путь работал — но только для тестового аккаунта. 📌 **Правило приёмки:** проверять **от имени заказчика**, его учёткой. Если система авторизует по `principal`/`user_id` — тест обязан идти с `user_id` заказчика. Сквозной путь, подтверждённый подставным аккаунтом, доказывает **только** работоспособность конвейера, но **не** работоспособность для пользователя. 🔴 **Красный флаг:** в тестовой ленте автором успешного запроса стоит НЕ тот `user_id`, который будет в проде. Это уже повод не объявлять приёмку. ⚠️ Отличать от НЕ-дефекта: пост **без** упоминания бота отбрасывается намеренно — `dropping posted event: bot not mentioned` (`MentionsBot`). В канале обращаться `@balda <текст>`; в DM упоминание не нужно. --- ## 🔴 Два линтера спорят на одной строке теста (Go) В `runtime`-репо, файл `agentfactory/duplicate_toolset_test.go`: ```go var asToolset tool.Toolset = ts // ← staticcheck ST1023: «omit type, it's inferred» asToolset := tool.Toolset(ts) // ← unconvert: «unnecessary conversion» ``` **Оба замечания валидны и взаимоисключающи** — правка одного рождает другое. Цикл: `ST1023` → чинишь → `unconvert` → чинишь обратно → `ST1023`. 📌 **Решение — не вводить переменную вообще.** Проверять тип напрямую: ```go if _, ok := any(ts).(requestProcessor); ok { t.Fatalf("MCP toolset unexpectedly implements ProcessRequest; dedup premise changed") } ``` ⚠️ **Побочный эффект:** после этого импорт `google.golang.org/adk/v2/tool` становится неиспользуемым → компилятор валит `"tool" imported and not used`. Убирать импорт **вместе** с правкой, не отдельным шагом. 📌 **Правило:** если два линтера требуют противоположного на одной строке — это сигнал не выбирать сторону, а **убрать конструкцию**, из-за которой возник спор. ### Гейты перед PR в `runtime` (проверенные) ```bash go build ./... # exit 0 go vet ./... # exit 0 go tool golangci-lint run ./... # 0 issues go test -race ./... # все пакеты ok ``` ⚠️ `go test -race ./...` на этом репо **дольше 600 с** (лимит foreground-таймаута) — запускать в фоне с `notify_on_complete`, а не ужимать лимит. --- ## 🔴 Чинить в КОДЕ, а не в данных (двойной префикс вернулся) В прошлой сессии я нашёл, что `principal` в `balda_user_bindings` записан как `mattermost:c6udeze…` (с префиксом), и починил это **прямым `UPDATE` в БД**. Дефект **вернулся**: `display_name` остался `mattermost:mattermost:c6udeze…`, а `created_by` новых channel-токенов снова получил двойной префикс. **Настоящий источник — код**, `actors/command/start/handler.go:420`: ```go case transportMattermost: return auth.MattermostSubject(strings.TrimPrefix(trimmed, transportMattermost+":")) ``` `MattermostSubject()` (`auth/owner.go:307`) **сама** добавляет префикс вида `:`, а `p.Principal` приходит от MM-хендлера **уже с префиксом** (`mattermost_ingress.go:147`: `Principal: auth.MattermostSubject(cmd.SenderID)`). 📌 **Правило: дефект, найденный в данных, искать и в коде.** Правка данных без правки кода = дефект вернётся при следующей записи. Проверять **оба слоя** и фиксить источник. ### ✅ ИСПРАВЛЕНО 2026-09-26 (сессия PR-подготовки) — фикс в хелпере Вместо костыля `TrimPrefix` в вызывающем коде сделана **идемпотентной сама `MattermostSubject`**: ```go // MattermostSubject returns the channel-qualified subject for a Mattermost user. // It is idempotent: a value that is already channel-qualified ("mattermost:") // yields the same subject, so callers may pass either a raw user ID or a subject // without risking a doubled prefix ("mattermost:mattermost:"). func MattermostSubject(userID string) string { trimmed := strings.TrimSpace(userID) trimmed = strings.TrimPrefix(trimmed, ChannelMattermost+":") return fmt.Sprintf("%s:%s", ChannelMattermost, trimmed) } ``` `userSubject` вернулся к симметричному с другими транспортами виду: ```go case transportMattermost: return auth.MattermostSubject(trimmed) ``` 📌 **Почему фикс в хелпере, а не в вызывающем:** `MattermostSubject` вызывается из **трёх** мест (`userSubject`, `mattermost_ingress.go:147`, `authorizeInbound`). Идемпотентность закрывает все пути сразу и защищает будущие вызовы поверх готового subject'а. Дельта `tools/deltas-mattermost-23-prefix-idempotent.json`. **Общий приём для нового транспорта:** любой `Subject(id)`-билдер делать **идемпотентным** — срезать уже существующий `:`-префикс. Тогда вызывающий код может передавать и raw id, и готовый subject без риска двойного префикса. Это дешевле, чем искать все call-site'ы, и переживает добавление новых. **Регресс-тесты:** `TestMattermostSubjectIsIdempotent` (`handlersfx/mattermost_ingress_test.go`), `TestMattermostHandlerSendsChannelQualifiedPrincipalExactlyOnce`. ⚠️ Тот же класс ошибки — двойное применение helper'а, который сам добавляет префикс. Признак: строка вида `::` в БД, в `display_name`, в `created_by`, в логах. Искать надо `MattermostSubject`/`TelegramSubject`-подобные билдеры, вызванные поверх уже собранного subject'а. ⚠️ Остальные вызовы `MattermostSubject(cmd.SenderID)` в `mattermost_ingress.go` (строки 189, 277, 292) **корректны** — они получают чистый `user_id`. Дефект ровно один, в `userSubject` (`handler.go:420`). --- ## 🔴🔴 Scope-классификатор нового транспорта: написать мало — надо зарегистрировать в ДВУХ картах Найдено 2026-09-26 (Балда на Kraken, MCP-инструменты не подключались). Класс ошибки: **функция есть, регистрации нет** — код компилируется, тесты зелёные, отказ виден только в рантайме. **Симптом:** MCP-инструменты не подключаются к сессии. В чате — «namespace `` не подключён к сессии», в логе `function_call_part_count: 0` (модель не вызвала инструмент). При включённом `session_memory` сброс сессии падает: ``` warn … "observe reset boundary: no session-memory classifier is registered for \"mattermost\"" → в чате: "Could not reset this session." ``` **Причина:** `sessionmemoryapp.ScopeResolver` собирается из карты классификаторов по `channelType`. Новый транспорт (для Mattermost — коннектор писал Alex) имел `ClassifyLocatorScope` в `channel/mattermost/locator_codec.go`, но **не был добавлен в карту**. Резолвер fail-closed: `classifier == nil` → `CodeUnsupportedScope` (`sessionmemoryapp/capture.go:68-75`). 🔴 **Карт ДВЕ, править обе:** - `internal/apps/balda/app.go` — рантайм-граф - `internal/apps/balda/preflight.go` — граф валидации Расхождение между ними даёт «конфиг прошёл preflight, но упал в рантайме» (и наоборот). ```go sessionmemoryapp.NewScopeResolver(map[string]sessionmemoryapp.ScopeClassifier{ baldatelegram.ChannelType: baldatelegram.ClassifyLocatorScope, baldaslackagent.ChannelType: baldaslackagent.ClassifyLocatorScope, baldazulip.ChannelType: baldazulip.ClassifyLocatorScope, baldamattermost.ChannelType: baldamattermost.ClassifyLocatorScope, // ← добавить }) ``` ⚠️ Побочный эффект импорта с алиасом: если пакет уже импортировался без алиаса (`"…/channel/mattermost"`), все обращения вида `mattermost.X` в файле надо перевести на `baldamattermost.X` — иначе `undefined: mattermost` (как в §«Дефект 3»). 📌 **Добавлять в чек-лист нового транспорта** (продолжение списка из 12 пунктов выше): 13. 🔴 `ClassifyLocatorScope` зарегистрирован в карте `ScopeResolver` **в обоих** файлах — `app.go` и `preflight.go`. ### Смежное: `session_memory` — выключатель MCP-биндера, и это НЕ `memory` ```go // app.go:275-278 — выключатель fx.Annotate(func() bool { return cfg.Balda.SessionMemory.Enabled }, fx.ResultTags(`name:"balda_session_memory_enabled"`)) // sessionapp/module.go:191-196 — биндер MCP func(broker *sessionmemorymcp.ContextBroker, enabled bool) baldaagent.SessionMCPBinder { return SessionMCPBinderAdapter{Broker: broker, Enabled: enabled} } ``` ⚠️ **`balda.memory` и `balda.session_memory` — разные секции.** Первая включает память, вторая — MCP-биндер сессии. С `memory`-only инструменты в сессию не подключаются. 📌 **MCP-серверы подключаются через секцию ПРОВАЙДЕРА**, не через `balda.mcp_servers`: ``` effective = bundled(balda) + runtime.providers..mcp_servers + balda.mcp_servers ``` 🔴 **Не перечислять ID серверов ДВАЖДЫ** (`balda.mcp_servers` **и** `runtime.providers..mcp_servers`) — эффективный набор это **объединение**, и каждый сервер подключается дважды. Симптом — ход падает ещё до вызова модели: ``` error … "agent run: duplicate tool: \"balda.control.shutdown\"" → в чате тишина; в логе `turn task failed` + `command deadlettered` (5 попыток) ``` Источник ошибки — библиотека ADK, не Балда: ```go // google.golang.org/adk/v2/tool/toolutils/toolutils.go:47-48 if _, ok := req.Tools[name]; ok { return fmt.Errorf("duplicate tool: %q", name) } ``` 🔴 **`duplicate tool` в исходниках Balda не найти** (`grep` пустой) — это `google.golang.org/adk`. Искать в `$(go env GOMODCACHE)/google.golang.org/adk/`, а не в репозитории. 📌 Правило: держать список ID в **одном** месте — у провайдера; `balda.mcp_servers: []`. Слияние в `session/manager.go:251-270` дедуплицирует **ID**, но не защищает от повторной регистрации **инструментов** уже подключённого сервера. ⚠️ **`session_memory.enabled: true` тянет за собой structured-output память.** На `deepseek-chat` worker памяти не может отдать нужную JSON-схему: ``` warn … session-memory capability retry limit exhausted operation=dead_letter kind=boundary.v1 error_code=model_failure error: output does not match schema: (root): output is required ``` 7 попыток → dead letter. Ответы бота при этом работают, но **память не наполняется**. Не блокер для MCP; отдельный дефект совместимости модели и схемы derivation. --- ## 🔴🔴 Подключение 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 --message '…'` | `creating posts is not supported in local mode` | `--local` ходит через unix-сокет: **чтение разрешено, запись от имени человека — нет** | | `mmctl post create --channel ` | `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 падений (`cmd/balda`, `internal/apps/balda`, `mcpruntime`, `session`, тесты layout `sessionmemory`). **Ни одно не было регрессией** от правки — те же падения на чистом дереве. ```bash git stash go test -race ./internal/apps/balda/ ./internal/apps/balda/session/ \ ./internal/apps/balda/mcpruntime/ ./cmd/balda/ > /tmp/baseline.log 2>&1 git stash pop # сравнить списки `^--- FAIL` — совпали? значит не регрессия ``` 📌 Объявлять регрессией только то, чего **нет** в baseline. Иначе доклад «я сломал 7 тестов» при нетронутом коде. ⚠️ Побочное наблюдение: фоновая сборка `docker buildx build` **без `--load`** кладёт результат только в кеш («No output specified with docker-container driver») — образ не появится в `docker images`, и последующий `docker save` провалится. Собирать с `--load`. --- ## 🔴 Прогресс-политика должна совпадать на command- и turn-пути одного транспорта Найдено 2026-09-26 (сессия PR-подготовки). `handlersfx/mattermost_ingress.go` в `HandleCommand` ставил `ProgressPolicy{Typing: true, PlanUpdates: true}`, а inbound-путь (`channel/mattermost/inbound.go:135`) и `Adapter.SendTyping` — `Typing: false`. **Почему это дефект:** у ботов Mattermost **нет typing API**, `SendTyping` — осознанный no-op (`return nil`). При `Typing:true` `SendProgress` вызывает `SendTyping`, тот молча ничего не делает → мёртвая ветка. Она не падает, поэтому компилятор и линтер её не видят. 📌 **Правило:** command-путь и turn-путь **одного и того же** транспорта обязаны давать **идентичную** `ProgressPolicy`. Расхождение — невидимый мёртвый код. Проверять оба места: в `handlersfx/*_ingress.go` (публикация команды) и в `channel/*/inbound.go` (normalized turn). 📌 **Метод поиска:** сравнить `ProgressPolicy` в обоих файлах транспорта. Если транспорт не поддерживает typing — `Typing: false` **везде**. **Фикс:** дельта `tools/deltas-mattermost-24-typing.json`; тесты `TestMattermostHandlerUsesMarkdownWithoutTypingProgress`, `TestNormalizeInboundDisablesTypingProgress`. ## 🔴 Тесты нового транспорта: REST через `httptest`, WS через `Upgrader`, хендлер напрямую Метод, отработанный 2026-09-26 на MM (опубликован в PR — воспроизводить для любого нового транспорта): | Слой | Как тестировать | |---|---| | **REST-клиент/адаптер** | `httptest.NewServer` + fake-handler на `POST /api/v4/posts`, запоминающий запросы; инъецируемый `respond(attempt int, req) (status int, body string)` для проверки fallback (1-й → 400, 2-й → 201) | | **WebSocket-ингресс** | реальный `websocket.Upgrader` в `httptest` + `websocket.DefaultDialer` — **единственный** способ проверить, что `writeEvent` всегда шлёт `seq` | | **Хендлер** | тестировать **структуру напрямую** (`&xInboundHandler{...}`), без fx — изолирует авторизацию, префикс principal, nil-safety | | **Фикстуры** | `fake...OwnerKVStore` + `recording...CommandIngress` по образцу `zulip_ingress_test.go` | 📌 **Обязательные регресс-тесты для нового транспорта:** 1. `writeEvent` всегда шлёт `seq` (регрессия `bad_seq`). 2. `seq` уникален под конкуренцией (16 горутин) — иначе MM увидит переупорядоченный кадр. 3. `Subject` идемпотентен (регрессия двойного префикса). 4. `ProgressPolicy` совпадает на command- и turn-пути. 5. Fallback только на content-rejection (400), **не** на 5xx. 6. `/start` доступен до авторизации. 7. `Start` отказывается без обязательных конфигов (bot user id и т.п.). ### ⚠️ Метод: падающий тест — сначала перечитать контракт, а не «чинить» код Три первых теста MM падали, и **все три** оказались неверными предположениями теста, а не дефектами кода: | Я предположил | Реальность | |---|---| | тред-сессия == сессия root-поста | тред — **отдельная** сессия от канала (`mm-t-…` vs `mm-c-…`), это правильный дизайн | | `NewIngress` подставляет `bot_user_id` из клиента | не подставляет; гард — в `onStart` («refuses to start without bot user id») | | отказ неавторизованному возвращает `nil` | возвращает `runtime is unavailable` (нет dispatcher'а), но команда **не публикуется** — это и есть контракт | 📌 **Правило:** когда новый тест падает — сначала перечитать код и понять, **чей** это контракт. «Привести код к тесту» — неверный рефлекс: тест мог быть написан по неверному предположению. Из трёх падений ноль были дефектами. 📌 **Побочная находка (дизайн MM-локатора):** root-пост (`RootID=""`) → `mm-c-`, ответ в треде (`RootID=`) → `mm-t-`. **Тред — отдельная беседа от канала.** Соответствует комментарию в `locator_codec.go`, но означает: бот в треде ведёт сессию, отдельную от канала. Зафиксировано тестом. --- ## 🔴🔴 Дефект 8: замкнутый круг привязки, когда binding занят чужим аккаунтом Ситуация: владельцем привязан **не тот** аккаунт, а нужно привязать правильный. Все штатные пути оказываются закрыты. | Штатный путь | Почему не работает | |---|---| | `/start owner=` | `HasOwner()` = `len(OwnerSubjects()) != 0`. Владелец существует → код уходит в `handleExistingOwnerState` → `IsOwner(<нужный аккаунт>)` = false → **«Bot owner is already registered»** | | `/start ` для нужного канала | `CreateMissingOwnerBindTokens` (`auth/channel_token.go:215`) **пропускает** канал с `HasOwnerSubject(channel) == true`. Канал считается привязанным → **токен не создаётся вообще** | | `registerOwner` → `attachCanonicalBinding` | Кандидат ищется как `Role == administrator` **И `Binding == nil`** (`canonical_users.go:110`). Единственный админ уже имеет binding → `no unbound active administrator user` | 📌 **Механика, которую надо помнить:** - `HasOwner()` при `canonical != nil` — это `len(OwnerSubjects()) != 0`, а `OwnerSubjects()` собирается из **binding'ов** (не из роли). Снять binding = обнулить `HasOwner()`. - `AttachBinding` **не даёт перепривязать** (`count != 0` → `ErrBindingAlreadyAssigned`), **detach-API отсутствует**. - `purpose` channel-токена должен быть `owner_bind` — токен из другого назначения `ConsumeOwnerBind` отвергнет. 🔴 **Единственный выход без правки кода** — `UPDATE` строки binding: меняем `principal` (и `display_name`) на нужный аккаунт. Пользователь, роль и `HasOwner()` сохраняются, связь «владелец ↔ аккаунт» переезжает. Обратимо обратным `UPDATE`. ```sql UPDATE balda_user_bindings SET principal = '<нужный mm_user_id>', display_name = 'mattermost:<нужный mm_user_id>' WHERE channel_type = 'mattermost' AND principal = '<старый mm_user_id>'; ``` ⚠️ Перед `UPDATE` — бэкап `state.db` **вместе с `-wal`**; контейнер остановить (sqlite не любит конкурентной записи). Значения проверять через `sqlite3 … ".schema balda_app_kv"` — колонки `namespace, key, value_json, updated_at, expires_at` (`value`/`k` таких колонок НЕТ). 📌 **Диагностика «почему именно мне бот не отвечает»:** сравнить `user_id` из ленты канала с `principal` в `balda_user_bindings`. Несовпадение = `unauthorized`, и никакие логи транспорта этого не покажут. --- ## ⚠️ Приёмка, проверяемая агентом (без GUI-браузера) — но ОТ НУЖНОГО АККАУНТА Требование Alex: «приёмка = видно в UI, не "контейнер Up" и не "лог said accepted"». Агент без браузера **не может** посмотреть UI — но может верифицировать ответ бота однозначно, читая **ленту канала** REST'ом и сверяя `user_id` автора: ```bash ssh kraken 'curl -s -H "Authorization: Bearer *** /home/kraken/balda/secrets/mm-bot-token)" \ "http://localhost:8065/api/v4/channels/7bjw4g7o1jdsbmjbqd649xxgaw/posts?per_page=4" \ | jq -r ".order[] as \$o | .posts[\$o] | \"\(.user_id) | \(.message)\""' ``` Ожидаемый вид — пост человека и **пост с `user_id` = bot_user_id**: ``` c6udeze8dfrdxm5jbgq55bd58r | @balda reply with a single word edxrgupoxiyg5gzmr79tjypbme | работает ← это и есть доказательство ``` 📌 Это заменяет «видно в UI»: `user_id` автора поста — однозначный факт, не интерпретация. ⚠️ `post_user_id` в логах ингресса **не** подходит: там id отправителя запроса, не автора ответа. ⚠️ Не отчитываться «готово» по факту `delivery settled` в логах — доставляется ли пост, видно только из ленты. 🔴🔴 **Обязательное условие: `user_id` отправителя в тесте должен быть аккаунтом ЗАКАЗЧИКА.** Тест от подставного/тестового аккаунта доказывает работу конвейера, но не работу для пользователя — см. §«Ложная приёмка» выше. Этот самый промах и дал ложный доклад «фаза закрыта», после которого Alex написал «Нихуя он мне не отвечает в ММ. Не готово!!». --- ## 🔴 Mattermost Team Edition: что недоступно (проверено) `mattermost version` → `Build Enterprise Ready: false`. На TE **невозможно** включить push в официальных мобильных приложениях — это архитектура продукта, не настройка. | Что | Факт | |---|---| | **Тумблер push** | Живёт в `EmailSettings.SendPushNotifications`, **не** в `ServiceSettings` — `mmctl config get ServiceSettings.EnablePushNotifications` даёт `Error: invalid key` | | **Официальный push-прокси** | Жив (HTTP 200), но для TE-`DiagnosticId` отвечает `Did not send message because of missing platform property` — APNs/FCM-сертификаты только у лицензированных EE | | **Свой MPNS** | Требует **собственные подписанные сборки** мобильных приложений + Apple Developer ($99/год) + FCM. Официальные клиенты прошиты на bundle ID Mattermost Inc. и к своему MPNS не подключатся | | **SMTP-почта** | Единственный путь без лицензии. `SendEmailNotifications: true` уже стоит, но SMTP не настроен (`localhost:10025`, на хосте ни одного слушателя) | 🔴 **Проба прокси — читать ТЕЛО, не статус.** `curl -X POST https://push.mattermost.com/api/v1/send_push` возвращает **HTTP 200** даже на невалидный `server_id`; решение в теле: `{"status":"FAIL","error":"…"}`. HTTP-код здесь — ложный сигнал успеха. ⚠️ `DiagnosticId` ищется так (таблица **`systems`**, не `Systems`; колонка `ServerId` отсутствует): ```bash docker exec mm-postgres psql -U mmuser -d mattermost -t -c \ "SELECT name, value FROM systems WHERE name ILIKE '%diagnostic%';" ``` 🔴 **Кавычки внутри `psql -c` в ssh-строке ломаются**: `name IN ('DiagnosticId')` уходит в shell до psql → Postgres видит `column "DiagnosticId" does not exist`. Экранировать `'"'"'` либо использовать `ILIKE` с шаблоном без значения в кавычках. 📌 MM-стек на Kraken: `/home/kraken/mattermost/docker-compose.yml`, 3 сервиса (`postgres` → `mm-postgres`, `mattermost`, сеть `mm-net` → `mattermost_mm-net`). БД: `docker exec mm-postgres psql -U mmuser -d mattermost`. --- ## 🔴 `go build` = exit 0 ≠ рабочий транспорт Компилятор не видит ошибок DI-графа. `go build ./...` проходит, а приложение падает на старте с `fx.ValidateApp`. Проверять транспорт **только** реальным запуском валидации: ```go prepared, err := loadBaldaCommandConfig(true) // нужен os.Chdir в tempdir с .config/balda/config.yaml err = validateBaldaApplication(prepared) // ← вот это и есть проверка графа ``` Эталонный тест: `TestValidateCommandRunsValidationWithMattermostTransport` в `cmd/balda/command_test.go`. На нём поймано 3 дефекта, которые `go build` пропустил. --- ## 🔴 Дефект 1: `fx.In` + внешний `fx.ParamTags` **Симптом:** ``` Failed: invalid annotation function func(mattermostfx.IngressParams) (*mattermost.Ingress, error): fx.In structs cannot be annotated with fx.ParamTags or fx.From ``` **Причина:** `fx.Annotate(NewIngress, fx.ParamTags(...))`, где `IngressParams` **уже** содержит `fx.In`. Теги обязаны жить внутри структуры, снаружи аннотировать нельзя. **Неправильно:** ```go fx.Annotate(NewIngress, fx.ParamTags(``, ``, ``, `name:"balda_mattermost_enabled"`, `name:"balda_mattermost_bot_user_id"`, `name:"balda_mattermost_bot_username"`, ``), ), ``` **Правильно:** ```go // структура — теги на полях type IngressParams struct { fx.In Processor mattermost.InboundProcessor `optional:"true"` Client *mattermost.Client Commands *commandcmd.Registry // обязательный, не optional Enabled bool `name:"balda_mattermost_enabled"` BotUserID string `name:"balda_mattermost_bot_user_id"` BotUsername string `name:"balda_mattermost_bot_username"` Logger zerolog.Logger } // предоставление — голое, без fx.Annotate fx.Provide(NewIngress), ``` Эталон в апстриме: `zulip.ServerParams` + `zulip.NewServer` в `zulipfx/module.go` (строка 24) — `zulip.NewServer` объявлен **без** `fx.Annotate`. ⚠️ Поле-порт (`*commandcmd.Registry`) делать **обязательным**. `optional:"true"` на нём — лишнее: в графе он есть всегда, а optional маскирует ошибки конфигурации. --- ## 🔴 Дефект 2: нет провайдеров зарегистрированных строк **Симптом:** ``` missing types: - string[name="balda_mattermost_server_url"] (did you mean to Provide it?) - string[name="balda_mattermost_token"] - string[name="balda_mattermost_bot_user_id"] ``` **Причина:** модуль читает значения через `name:`-теги, а провайдеров этих имён в composition root нет. У Zulip они есть (`balda_zulip_*`), для нового транспорта забыли. **Правило: каждый `name:`-тег в модуле = обязательный провайдер в `app.go`.** ```go fx.Provide( fx.Annotate( func() string { return strings.TrimSpace(cfg.Balda.Mattermost.ServerURL) }, fx.ResultTags(`name:"balda_mattermost_server_url"`), ), ), ``` `strings.TrimSpace` для отсутствующего ключа YAML даёт `""` — без паники, как у Zulip. Образец: блок `balda_zulip_*` в `internal/apps/balda/app.go` (~строки 576-606). --- ## 🔴 Дефект 3: забытый импорт корневого пакета `app.go:965: undefined: mattermost` при вызове `mattermost.ValidateConfig`, если импортирован только `mattermostfx`. Транспорт требует **оба** импорта: ```go "github.com/baldaworks/balda/internal/apps/balda/channel/mattermost" "github.com/baldaworks/balda/internal/apps/balda/channel/mattermost/mattermostfx" ``` --- ## 🔴🔴 Дефект 4: объявленная команда без actor-хендлера (роняет ВЕСЬ fx-граф) Найден **только реальным запуском на Kraken** (Фаза 3). До этого Фаза 2 считалась закрытой: `go build` = 0, `go vet` = 0, `golangci-lint` = 0 issues, `go-arch-lint` = OK, тесты зелёные. **Ни один из этих гейтов его не видит.** **Симптом** — crash-loop контейнера (`Restarting (1)`), в логах одна строка на тысячи `"message":"provided"`: ``` Error: starting Balda app: could not build arguments for function ".../execution".init.func1: failed to build *execution.ActorHost: ... failed to build *command.Router: received non-nil error from function "reflect".makeFuncStub: advertised commands have no actor handler: goal ``` **Причина:** в `mattermostfx/module.go` в `commandcmd.Advertisement{Names: [...]}` была объявлена команда `goal`, которой **не существует** как actor-хендлер. `goal` — это **payload kind** (`goalkeepercmd.PayloadKindGoal`, `actorcmd.KindGoal`, `actors/job_actor.go:100`), а снаружи цикл GoalKeeper называется `goalkeeper`. **Механизм:** `actors/command/router.go:49 ValidateAdvertised()` вызывается из `commandfx/module.go:306`. Он валидирует **весь** список `Names` одного advertisement и при первом отсутствующем имени возвращает ошибку → fx не может построить `*command.Router` → падает `*execution.ActorHost` → падает приложение. Одна опечатка в списке = полный отказ старта, а не «недоступна одна команда». **Правильно — сверять `Names` с реальными `Name()`:** ```bash cd ~/Developer/balda grep -rn 'func (h \*Handler) Name() string' internal/apps/balda/actors/command/ ``` Эталонный фактический список (2026-09-26, `balda@de273d8f`): ``` locator · reset · usage · auto · cancel · close · start · user · topic · skill goalkeeper · help · plugin ``` `mattermostfx/module.go` после фикса: ```go Names: []string{ "locator", "reset", "usage", "auto", "cancel", "goalkeeper", "topic", "close", "start", "user", "skill", }, ``` `channel/mattermost/inbound.go` — тот же список в inbound allow-list, синхронно. ⚠️ `Zulip`-адаптер изначально был **прав** — там `"goalkeeper"` без `"goal"`. Регрессию внёс MM-адаптер при копировании «на глаз». ⚠️ **`docs/commands.md` — НЕ источник истины** для имён команд. Там таблица доступности по транспортам; реестр хендлеров — только `actors/command/*/handler.go`. ⚠️ Фикс — дельта-апплаером: `tools/deltas-goal-command-fix.json` (2 дельты, обе APPLIED). 📌 **Общий вывод (расширяет §«go build = exit 0 ≠ рабочий транспорт» выше):** недостаточно проверять DI-граф тестом `validateBaldaApplication`. Тест должен прогоняться **с реальным списком `Names` из модуля**. Но и это не поймало бы опечатку, если тест использует тот же неверный advertisement — поэтому единственная надёжная проверка нового транспорта = **живой запуск бинаря**. Для нового транспорта добавлять в чек-лист шаг: `docker run` (или локальный `balda start`) с включённым транспортом **до** объявления готовности. ✅ **Фикс подтверждён живым запуском (2026-09-26).** После снятия `goal` контейнер `balda-mm` на Kraken перестал падать и дошёл до рабочего состояния: ``` {"component":"balda.session_manager","balda_provider":"deepseek","message":"session manager ready"} {"component":"balda.channel.mattermost.ingress","url":"ws://mattermost:8065/api/v4/websocket", "message":"mattermost websocket connected"} {"message":"Balda bot started. Press Ctrl+C to stop."} ``` 📌 Порядок проверки, который сработал: правка дельтой → `go build ./...` → `go test ./internal/apps/balda/actors/command/...` → пересборка образа → `docker compose up -d` → `docker logs | grep -E "started|websocket connected"`. **Не** объявлять транспорт готовым по «exit 0» и «conтейнер Up» — только по этим двум строкам в логах. --- ## ⚠️ Env-override работает только по ключам, уже присутствующим в YAML `appconfig/loader.go:367` — `SetEnvPrefix("BALDA")` + `SetEnvKeyReplacer(".", "_")` + `AutomaticEnv()` **по leaf-ключам конфига**. Поэтому блок нового транспорта обязан быть в `cmd/balda/balda.yaml` — иначе `BALDA__*` переменные **молча игнорируются**. Порядок для нового транспорта: 1. блок в `cmd/balda/balda.yaml` (иначе env мёртв) 2. `MattermostConfig`-подобная структура + поле в `BaldaConfig` 3. провайдеры в `app.go` 4. `validateConfig` + вызов рядом с `validateSlackConfig` 5. включить в `validateBaldaChannelConfiguration` (`cmd/balda/command.go`) — иначе деплой только на этом транспорте отбивается «at least one channel is required» 6. `.go-arch-lint.yml` — компоненты `_transport`/`_wiring` + права у `composition_root` и `ingress_wiring` (см. §go-arch-lint выше) 7. тест на `validateBaldaApplication` с `.enabled=true` (см. §«go build ≠ …» выше) 8. тест env-override в `cmd/balda/command_test.go` (по образцу `TestLoadConfigDocument_AppliesZulipEnvOverrides`) 9. если добавлял маршрут в `BuiltinRoutes()` — обновить `TestBuiltinRoutes` 10. доки: `docs/reference/configuration.md` (ключи), `docs/commands.md` (колонка транспорта) 11. 🔴 **сверить `Advertisement.Names` с реальными `Name()`** в `actors/command/*/handler.go` — опечатка роняет весь fx-граф (см. §«Дефект 4» выше) 12. 🔴 **живой запуск** `balda start` (или `docker run`) с включённым транспортом — единственная проверка, которая ловит дефект 4 **Финальные quality gates (все обязательны, из `AGENTS.md`):** ```bash go build ./... # 0 go vet ./... # 0 go tool golangci-lint run # 0 issues go tool go-arch-lint check --project-path . # OK go test -race ./... ``` ⚠️ `go-arch-lint` **не входит** в `golangci-lint` — запускать отдельно, иначе ошибка архитектуры всплывёт только на CI/ревью. --- ## 🔴🔴 Дефект 5: `optional:"true"` прячет отсутствующую зависимость (ингресс-приёмник) Найден 2026-09-26 (Фаза 3, сессия реализации). **Самый коварный из дефектов транспорта:** приложение стартует нормально, событий не обрабатывает, и **ни один гейт этого не видит**. **Симптом:** событие `posted` доходит до ингресса, логируется как `mattermost post accepted for processing` — и дальше тишина. Ни ошибки, ни warn'а. **Причина:** в `mattermostfx/ingress.go`: ```go type IngressParams struct { fx.In Processor mattermost.InboundProcessor `optional:"true"` // ← никто не предоставляет … } ``` В `handlersfx/` были только `zulip_ingress.go` и `telegram_ingress.go` — **MM-хендлера не существовало**. fx не падает на отсутствующей `optional`-зависимости, поэтому ингресс получал `Processor == nil` и молча выходил: ```go if s.processor == nil { return // ingress.go, processPosted } ``` 📌 **`optional:"true"` — это утверждение «отсутствие допустимо», и его надо осознанно подтверждать.** Если порт обязателен для работы транспорта — маркер должен быть **снят**, тогда fx упадёт на старте и дефект будет виден сразу. **Правило для нового транспорта:** у каждого порта в `*fx`-модуле проверить, **кто его предоставляет**. Если никто — транспорт не работает, даже если `docker ps` показывает `Up`. **Фикс:** `handlersfx/module.go` — ```go fx.Annotate( newMattermostInboundHandler, fx.As(new(baldamattermost.InboundProcessor)), ), ``` (дельта `tools/deltas-mattermost-19-wire.json`). --- ## 🔴 auth: subject-based авторизация — новый транспорт надо регистрировать в трёх местах Авторизация Balda проверяет **subject** вида `:` (`telegram:101`, `zulip:42`, `slackagent::`). Для нового транспорта это три отдельные правки, и **забытая любая из них даёт молчаливый отказ авторизации** (не ошибку!): | Файл | Что добавить | Симптом при пропуске | |---|---|---| | `auth/channel_token.go` | `Channel = ""` + в `CreateMissingOwnerBindTokens` | транспорт не получает owner-bind токен | | `auth/canonical_users.go` | ветку `case Channel:` в `canonicalSubject` | 🔴 `unsupported transport ""` → **все** `IsOwnerSubject`/`GetCollaborator` = false | | `auth/owner.go` | функцию `Subject(id)` | нет канонического билдера subject'а | ⚠️ `switch channelType` в `canonicalSubject` заканчивается `default: return error`. Неизвестный транспорт — **не panic**, а тихая ошибка, которую вызывающий код часто проглатывает (`if err != nil { return false }`). Искать надо по `canonicalSubject`. 📌 Приятное: `userSubject()` в `actors/command/start/handler.go` для нового транспорта **уже работает** через default-ветку `return transport + ":" + trimmed` — команду `/start` править не нужно. ✅ **Проверка, что авторизация доехала:** в логах хендлера `settlement_stage` перестаёт быть `"unauthorized"`. До привязки владельца он именно такой — **это норма, а не баг**. --- ## 🔴 WS-фрейм без `seq`: MM перестаёт пушить события, НЕ разрывая сокет **Это была настоящая причина «connected, но тишина»** (найдена 2026-09-26 диагностическим логом каждого кадра, дельта `deltas-mattermost-15-diag.json`). ```json {"status":"FAIL","error":{"id":"api.web_socket_router.bad_seq.app_error", "message":"Invalid sequence for WebSocket message."}} ``` раз в 30 с — ровно на каждый пинг. Балда слала `{"action":"ping"}` **без `seq`**. **Механизм:** Mattermost требует **монотонный `seq` в каждом клиентском фрейме**. На невалидном он помечает соединение сломанным и **прекращает доставлять `posted`**, при этом **сокет не закрывает** — поэтому `mattermost websocket connected` в логе остаётся, а событий нет. `seq` начинает требоваться с **первого** кадра. Если auth-челлендж убран (см. ниже), то счётчик надо начинать самому — иначе **ни один** исходящий фрейм не несёт `seq`. **Фикс** (дельта `deltas-mattermost-16-seq.json`): ```go type Ingress struct { … seq int64 // монотонный счётчик исходящих фреймов } func (s *Ingress) nextSeq() int64 { s.mu.Lock(); defer s.mu.Unlock() s.seq++ return s.seq } // Единственный путь отправки клиентского фрейма. func (s *Ingress) writeEvent(conn *websocket.Conn, action string, data any) error { frame := map[string]any{"seq": s.nextSeq(), "action": action} if data != nil { frame["data"] = data } return conn.WriteJSON(frame) } ``` + сброс `s.seq = 0` при установке нового соединения; + пинг через `s.writeEvent(conn, websocketActionPing, nil)`; + фрейм со `"status":"FAIL"` — **warn**, не игнор (иначе ошибка невидима). ✅ Проверено: `bad_seq` — 0 вхождений, в логах `pong seq_reply:1, 2, 3`. 📌 **Правило:** любая отправка в MM-сокет — **только** через `writeEvent`. «Сырой» `conn.WriteJSON`/`WriteMessage` без `seq` = тихо убитая подписка. --- ## 🔴 Диагностика: «пробник видит событие» ≠ «приложение видит событие» Отдельный урок метода, стоивший двух сессий. Пробник подключается **своим сокетом** и события получает; сокет приложения к тому моменту может быть уже «отравлен» (в нашем случае — `bad_seq`) и MM на него не пушит. Из этого родилась **ложная версия** «событие приходит, но теряется внутри `readLoop` → `handleFrame`». **Правильный диагностический приём:** временный лог **каждого кадра** в самом ингрессе: ```go s.logger.Debug(). Int("message_type", frame.messageType). Int("payload_bytes", len(frame.payload)). Str("payload_head", headOf(frame.payload, 160)). Msg("mattermost websocket frame received") ``` + лог в каждой точке отказа (`dispatchPosted`, фильтры, `MentionsBot`) — тогда «тишина» в логах перестаёт быть загадкой. Именно этот лог и вскрыл `bad_seq` за один прогон. 📌 Логи уровня `debug` **не удалять** после фикса: они уже окупились и окупятся снова. Точки отказа в `mattermost`-ингрессе: dial (с HTTP-статусом!), `frame received`, `event decoded`, `status:FAIL`, каждый `dropping posted event: <причина>`. --- ## 🔴 `go-arch-lint` — обязательный шаг для нового транспорта ⚠️ **Это не «опциональная придирка»: `AGENTS.md` прямо требует обновлять `.go-arch-lint.yml` в том же изменении, что и архитектуру.** Без правки линтер падает с двумя классами ошибок: ``` Component composition_root shouldn't depend on .../channel/mattermost File /internal/apps/balda/channel/mattermost/adapter.go not attached to any component in archfile ``` **Что добавить в `.go-arch-lint.yml` (4 правки) — по образцу `zulip`:** 1. **Секция `components:`** — пары `in:`-компонентов: ```yaml # Provider-owned Mattermost transport. It may depend only on # transport-neutral contracts; application/runtime adapters live in wiring. mattermost_transport: in: - internal/apps/balda/channel/mattermost - internal/apps/balda/channel/mattermost/presentation # Composition-root adapters for Mattermost delivery, prompt, and structured renderers. mattermost_wiring: in: - internal/apps/balda/channel/mattermost/mattermostfx ``` ⚠️ Root-пакет и `presentation/` идут в **один** компонент `_transport`, а `_fx`-пакет — отдельно в `_wiring`. Именно так у `zulip`/`telegram`/`slackagent`. 2. **Правила `mayDependOn`:** - `mattermost_transport` → `foundation, contracts, neutral_registry, mattermost_transport` - `mattermost_wiring` → `foundation, contracts, neutral_registry, application, delivery, storage, mattermost_transport, mattermost_wiring` 3. **`composition_root`** → добавить `mattermost_transport`, `mattermost_wiring` (иначе «composition_root shouldn't depend on …» — это ожидаемый отказ, там перечислены все транспорты). 4. **`ingress_wiring`** → добавить `mattermost_transport` (соседствует с `telegram_transport`, `zulip_transport`). Проверка: `go tool go-arch-lint check --project-path .` → должно быть `OK - No warnings found`. --- ## 🔴 `golangci-lint` на новом транспорте: мёртвый typing-throttle Первый прогон дал 4 замечания — все в новом коде. Важный урок: **не гейтить линтер nolint-комментарием, а удалять мёртвый код.** | Замечание | Что было | Фикс | |---|---|---| | `inamedparam` ×2 | `Start(context.Context) error` в interface-assertion | `Start(ctx context.Context) error` | | `unused` | `shouldSendTyping()` — недостижим, т.к. `SendTyping` всегда no-op | удалён | | `unused` | `websocketReceiveTimeout` — константа без использования | удалена | | `unlambda` | `func(client, logger) *Adapter { return NewAdapter(...) }` | заменено на `mattermost.NewAdapter` | **Корневая причина `unused`:** механизм typing-throttle (`shouldSendTyping`, поля `typingThrottleInterval`/`typingLastSentAt`, `typingMu sync.Mutex`, `SetTypingThrottleInterval`) был скопирован из Zulip по аналогии. Но у Mattermost **ботов нет typing API** (`SendTyping` осознанно возвращает `nil`), поэтому весь механизм недостижим. Удалён целиком — не заглушён. **Правило: при копировании транспорта с Zulip проверять каждую фичу на применимость.** Zulip-специфичное, что НЕ переносится в транспорт без поддержки: - typing-throttle (нужен интерактивный typing API) - логика `sendWithPlainFallback` — переносится, но только на content-rejection ⚠️ **Каскад после удаления полей:** удаление `typingThrottleInterval` потянуло `SetTypingThrottleInterval` → `sync` (импорт) → `zerolog` (импорт в `module.go`). Удалять надо **всей группой**, иначе серия `undefined`/`imported and not used`. --- ## ✅ Тест-регрессия: `BuiltinRoutes` и длина списка Добавление маршрута в `BuiltinRoutes()` (`deliveryfmt/routes.go`) **ломает** `TestBuiltinRoutes` в `deliveryfmt/registry_test.go`, если не обновить ожидание: ``` len(BuiltinRoutes()) = 5, want 4 ``` Фикс — добавить строку в map `want`: ```go {transport: TransportMattermost, deliveryFormat: DeliveryFormatNone}: NamePlainText, ``` 📌 Нашлось только сравнением «с правками / без правок» через `git stash` — в списке предсуществующих падений апстрима этого теста **не было**, значит это наша регрессия. --- ## ✅ 13 пакетов падают в апстриме изначально Не пытаться «чинить» как часть своей задачи — это дефект апстрима. Проверять `git stash` перед выводами о регрессии. Падающие в `baldaworks/balda@de273d8f`: - `TestInitCommand_NonInteractiveAutoSelectsRootAndGeneratesDetectedAgents` (cmd/balda) - `TestMigrateSessionMemoryLayout_*` ×4 (state) - `TestWorkspaceMCPImport/Export_UsesPersistedSessionMetadata` (session) - `TestPluginResolverResolvesContainedArgvFilteredEnvAndLaunchSecrets` (mcpruntime) - `TestResolveWorkspaceBaseBranch_ConfigPreferredWhenValidVal`, `TestCanonicalSessionMemory*` (apps/balda) **Метод проверки регрессии:** ```bash git stash push -q -m baseline --include-untracked go test ./<пакет>/ -count=1 # смотрим падения БЕЗ правок git stash pop -q # правки возвращаются ``` ⚠️ `git stash` может задеть чужие стэши — в форке лежат `whale-20260623` и `feat/zulip-events-polling`. Проверять `git stash list` до и после. --- ## Метод правок: дельта-апплаер вместо sed Правило Alex: **никаких in-place скриптов и sed**. Инструмент в `~/Developer/balda/tools/`: | Файл | Роль | |---|---| | `apply-project-deltas.py` | движок (JSON-дельты + состояние) | | `run-project-deltas.sh` | обёртка `check` / `apply` / `list` | | `deltas-.json` | группы правок | | `.deltas-state.json` | sha256 применённых якорей | ```bash cd ~/Developer/balda bash tools/run-project-deltas.sh check tools/deltas-mattermost.json tools/.deltas-state.json bash tools/run-project-deltas.sh apply tools/deltas-mattermost.json tools/.deltas-state.json ``` **Формат дельты:** ```json { "file": "путь/от/корня", "note": "что делаем", "search": "уникальный фрагмент текущего текста", "replace": "чем заменить" } ``` **Три исхода:** `replace` уже есть → `skipped` (идемпотентно); `search` ровно 1 раз → `applied`; ни того ни другого → `error: ЯКОРЬ НЕ НАЙДЕН`. 📌 `check` **обязателен перед `apply`** — на нём поймано, что дельта валидации целилась в несуществующий `validate()` в `config.go` (валидация живёт в `app.go`), и переписана до записи. ⚠️ Дельты с длинным `replace` писать в JSON через `write_file`, **не** heredoc — кавычки и `\n` ломаются молча. Это тот же питфолл, что у SSH-heredoc с вложенными кавычками. 🔴 **JSON дельт — ПЛОСКИЙ МАССИВ, не объект.** Формат-обёртка `{"deltas": [...]}` (или любой объект с ключами) роняет апплаер: ``` TypeError: string indices must be integers File "apply-project-deltas.py", line 91, in main path = root / d["file"] ``` Причина: `main()` итерирует `for i, d in enumerate(deltas)` — если `deltas` объект, `d` становится строкой-ключом. Проверено 2026-09-26 на `deltas-mattermost-14.json`. **Правильно:** файл начинается с `[`, а не с `{`. ⚠️ Обёртка `run-project-deltas.sh` требует **ТРИ** аргумента, а не два: `run-project-deltas.sh `. Без третьего — `line 9: 3: нужен файл состояния`. И `check` не поддерживает `--list`: `list` — отдельный глагол (`$1 = list`). --- ## 🔴 Mattermost WebSocket: авторизация — ОДИН механизм, не два Найдено живым запуском (Фаза 3, root-cause). Симптом: в логе `mattermost websocket connected`, но **ни одного** события `posted` в инбаунде, даже на `logger.level: debug`. При этом пробник (pure Go stdlib) ловил `posted` на том же сокете. **Две причины, наложившиеся друг на друга:** ### 1. `Origin` в handshake → 403 CORS Если клиент шлёт `Origin: http://mattermost:8065`, а `SiteURL=https://mm.qentra.top` — MM отвечает: ``` HTTP/1.1 403 Forbidden {"id":"api.web_socket.connect.upgrade.app_error","message":"URL Blocked because of CORS. Url: …"} ``` 📌 **`Origin` не слать вообще.** Балда его не шлёт — с ней этой проблемы нет. Это ловушка для пробников и внешних клиентов. ### 2. `authentication_challenge` поверх `Authorization: Bearer` → подписка умирает `channel/mattermost/ingress.go`, `streamOnce`: ```go conn, _, err := dialer.DialContext(ctx, endpoint, header) // header: Authorization: Bearer *** // … s.logger.Info().Msg("mattermost websocket connected") // ← лог УЖЕ «здоровый» conn.WriteJSON(map[string]any{ // ← 🔴 а это ломает подписку "seq": 1, "action": "authentication_challenge", "data": map[string]string{"token": token}, }) ``` **Эмпирика (пробник, `-authheader`):** - с челленджем: `hello` → `status_change` → **тишина 40 с**, хотя пост в канал создаётся; - без челленджа: `hello` → `posted` штатно. То есть избыточный челлендж заставляет MM переинициализировать сессию сокета, и `posted` на него больше не пушится. **Диагностически коварно:** `websocket connected` логируется **до** челленджа, поэтому лог выглядит здоровым. **Правило:** сокет авторизуется **либо** заголовком на dial, **либо** `authentication_challenge`. Оба сразу — тишина. Если заголовок есть, челлендж слать **нельзя**. **Фикс** — дельта `tools/deltas-mattermost-14.json` (2 дельты: убрать `WriteJSON`-блок и unused-константу `websocketActionAuthChallenge`). Образ `balda-mm:0.1.2`. 📌 **Общий урок для транспортов:** лог «connection established» ничего не говорит о том, что подписка жива. Проверять **фактом получения события**, а не фактом коннекта. Для MM — пробник из `~/Developer/balda-wsprobe/` (pure stdlib, RFC6455, собирается нативно на Mac, на хост кладётся статический бинарь). ### 🔴 MM 11.x: заголовок `Token:` для API мёртв `POST /users/login` отдаёт сессию в **заголовке ответа** `Token:`. Но использовать её нужно **только** как `Authorization: Bearer *** — заголовок `Token: ` в запросах даёт `401 api.context.session_expired.app_error` при живой сессии (в БД `expiresat` = +180 дней). Старый стиль MM 5.x больше не принимается. Проверено 2026-09-26. 📌 Отсюда же: если скрипт падает с `session_expired` — сначала проверить, каким **именем заголовка** шлётся токен, а не искать проблему в пароле/лимите сессий. --- ## 🔴 Дефект 6: точка входа онбординга, закрытая проверкой доступа Найдено 2026-09-26. `/start owner=` в DM получил от бота **«You are not allowed to use Balda in this channel.»** **Причина:** `HandleCommand` проверял доступ **до** обработки, а владельца ещё нет — тупик: привязаться нельзя, потому что не авторизован; авторизоваться нельзя, потому что не привязан. Владелец в canonical store привязывается **этой же командой**, поэтому она обязана быть доступна неавторизованному. **Неправильно:** ```go if !h.canAccess(ctx, cmd.SenderID) { return h.sendPlain(ctx, cmd.Locator, accessDeniedText) } ``` **Правильно** (образец — `zulip_ingress.go:172`): ```go if cmd.Command != commandStart && !h.canAccess(ctx, cmd.SenderID) { return h.sendPlain(ctx, cmd.Locator, accessDeniedText) } ``` ⚠️ `commandStart` **уже объявлена** в пакете `handlersfx` (приходит из telegram/zulip-веток). Своя константа даёт `commandStart redeclared in this block`. Использовать общую. 📌 **Правило для нового транспорта:** онбординг-команда — единственная, что доступна неавторизованному пользователю. Проверять это явно, иначе транспорт невозможно забутстрапить. --- ## 🔴 Дефект 7: `principal` с транспортным префиксом в `balda_user_bindings` Найдено 2026-09-26. `/start owner=` вернул успех («You are now registered as the bot owner»), но каждое обращение к боту оставалось `settlement_stage="unauthorized"`. **Механизм.** Авторизация сверяет subject вида `:`. `canonicalSubject()` **разрезает** его по первому `:` и **отбрасывает** префикс: ```go if before, after, ok := strings.Cut(trimmed, ":"); ok { channelType = strings.ToLower(strings.TrimSpace(before)) principal = strings.TrimSpace(after) // ← префикс отрезан } ``` А `GetUserByBinding` делает **точное** сравнение: ```go return s.getUser(ctx, `b.channel_type = ? AND b.principal = ?`, channelType, principal) ``` Если в БД записался `principal = 'mattermost:'` при `channel_type='mattermost'`, то поиск по `principal=''` не находит **ничего** — молча, без ошибки. 🔴 **Правило:** в `balda_user_bindings` `principal` хранится **БЕЗ** транспортного префикса. Префикс живёт только в `channel_type`. Нельзя собирать subject тем же хелпером, которым потом нормализуешь: `userSubject()` в `start/handler.go` оборачивает principal в `:` — если на вход уже пришёл обёрнутый principal, выходит двойная обёртка и запись уезжает с префиксом. **Диагностика — прочитать таблицу:** ```bash sudo -n sqlite3 /srv/…/docker/balda/state/state.db \ "SELECT channel_type, principal FROM balda_user_bindings;" # правильно: mattermost | c6udeze8dfrdxm5jbgq55bd58r # неправильно: mattermost | mattermost:c6udeze8dfrdxm5jbgq55bd58r ``` **Лечение** (`tools/mm-fix-owner-principal.sh`: бэкап → стоп → UPDATE → старт): ```sql UPDATE balda_user_bindings SET principal = substr(principal, length(channel_type) + 2) WHERE channel_type = 'mattermost' AND principal LIKE channel_type || ':%'; ``` ⚠️ Штатного пути **нет**: `AttachBinding` отказывает при существующей привязке (`count != 0` → `ErrBindingAlreadyAssigned`), detach-API отсутствует вовсе. Правка БД — вынужденная мера, делать с бэкапом и **остановленным** контейнером (sqlite не любит конкурентной записи). --- ## 🔴 Провайдер: `base_url` в YAML мёртв, работает только `OPENAI_BASE_URL` Найдено 2026-09-26. Симптом (образ `0.1.7`): ```json {"component":"balda.turn_dispatcher", "error":"agent run: send openai request: Post \"https://api.openai.com/v1/chat/completions\": tls: failed to verify certificate: x509: certificate signed by unknown authority"} ``` Запрос уходил на **`api.openai.com`**, хотя в `config.yaml` стоял `base_url: "https://api.deepseek.com/v1"`. **Причина:** тип `LocalAPIConfig` в `github.com/normahq/runtime/v2@v2.0.11/agentconfig/config.go` имеет **только** `api_key` и `model`. Поля `base_url` в схеме **нет**: ```go type LocalAPIConfig struct { APIKey string `json:"api_key,omitempty" yaml:"api_key,omitempty" mapstructure:"api_key"` Model string `json:"model,omitempty" yaml:"model,omitempty" mapstructure:"model"` } ``` Базовый URL читается **только из env** — `hostedagent/openai.go`: ```go const defaultOpenAIBaseURL = "https://api.openai.com/v1" func openAIBaseURL() string { if u := os.Getenv("OPENAI_BASE_URL"); u != "" { return strings.TrimRight(u, "/") } return defaultOpenAIBaseURL } ``` 📌 **Правило:** для любого OpenAI-совместимого провайдера (DeepSeek, локальные gateway и т.п.) базовый URL задавать **через `OPENAI_BASE_URL`** в `.env` или `environment:` compose. Ключ `base_url` в YAML **выглядит рабочим, но не делает ничего** — молчаливая ловушка. ⚠️ Сама переменная попадает в процесс только если `env_file`/`environment` настроены. **Фикс:** `OPENAI_BASE_URL=https://api.deepseek.com/v1` в `/home/kraken/balda/.env` (`tools/mm-add-base-url.sh`, идемпотентный, с бэкапом). --- ## 🔴 `FROM scratch` и TLS: нет доверенных корней → любой HTTPS падает `Dockerfile.release` Балды собирается из `scratch`. В финальном образе **нет `/etc/ssl/certs`**, поэтому попытка установить любое TLS-соединение заканчивается: ``` tls: failed to verify certificate: x509: certificate signed by unknown authority ``` Ошибка **обманчива**: выглядит как «удалённый сервер с неверным сертификатом», а на деле в образе физически нет файла с корневыми CA — Go не может построить цепочку доверия. Симптом проявился ровно один раз, на первом исходящем HTTPS (запрос к LLM). **Фикс — дельта `tools/deltas-mattermost-22-ca.json`:** ```dockerfile FROM scratch # CA bundle: the scratch base has no trust store, so any HTTPS call (the LLM # provider in particular) fails with 'x509: certificate signed by unknown # authority'. The builder stage is a Debian image and carries the bundle. COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt COPY --from=builder /out/balda /usr/local/bin/balda ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt ENTRYPOINT ["/usr/local/bin/balda"] ``` 📌 **Правило:** любой `scratch`/`distroless`-образ, делающий исходящие HTTPS-запросы, обязан содержать `ca-certificates.crt` **и** выставлять `SSL_CERT_FILE` (у distroless путь тот же). Копировать прямо из builder'а — он на Debian и бандл уже там. ⚠️ Альтернатива для локального дебага: `SSL_CERT_FILE` с хоста через bind-mount. Для прода — копировать в образ. --- ## Метод: чтение sqlite-состояния Балды (образ `scratch`) В образе **нет** ни `sh`, ни `sqlite3`, поэтому `docker exec` невозможен в принципе: ``` OCI runtime exec failed: exec failed: unable to start container process: exec: "sh": executable file not found in $PATH ``` **Рабочий порядок:** 1. Файлы состояния: `/state.db` + `state.db-wal` + `state.db-shm`, владелец — **root**. 2. `sqlite3` **есть на хосте** Kraken (`/usr/bin/sqlite3`), но не в контейнере. 3. Для остановленного контейнера — прямой `sqlite3` под `sudo -n`. 4. Для живого — `strings` (только просмотр, без записи). 🔴 **Читать `state.db` ВМЕСТЕ с `state.db-wal`.** Свежие записи живут в WAL, а основной файл выглядит пустым. Прямая потеря времени: таблица `balda_user_bindings` казалась пустой, хотя привязка владельца уже была записана — она лежала в `-wal`. ```bash STATE=/srv/…/docker/balda/state for f in state.db state.db-wal state.db-shm; do echo "--- $f" sudo -n strings "$STATE/$f" 2>/dev/null \ | grep -oE "(telegram|mattermost|slackagent|zulip):[A-Za-z0-9_-]+" | sort -u done ``` **Полезные ключи KV внутри `state.db`:** - `balda.appowner_auth_token` — owner auth token (32 символа), нужен для `/start owner=`; - `balda_user_bindings` — привязки, `principal` **без** префикса (см. §Дефект 7); - `balda_user_binding_claims` — claim'ы, `generated_binding_count`, `role IN ('administrator','operator')`. ⚠️ `sudo` через ssh требует `< /dev/null`, иначе `Connection closed by remote host`. ⚠️ Правка каталога данных (`sudo cp -a state `) уходит на апрув и **блокируется** по таймауту. Точечный `UPDATE` одного поля такого апрува не требует — предпочтителен. --- ## 🔴 Mattermost WS: `bad_seq` — пинг без `seq` убивает подписку молча Найдено 2026-09-26 диагностическим логом каждого кадра (`deltas-mattermost-15-diag.json`). **Это и была настоящая причина «connected, но тишина»** — до неё ошибочно считалось, что виноват `authentication_challenge` (он тоже вреден, но лишь как усугубляющий фактор). MM отвечал раз в 30 секунд, ровно на каждый пинг: ```json {"status":"FAIL","error":{"id":"api.web_socket_router.bad_seq.app_error", "message":"Invalid sequence for WebSocket message."}} ``` **Механизм:** Mattermost требует **монотонный `seq` в КАЖДОМ клиентском фрейме**. Балда слала `{"action":"ping"}` **без `seq`** → MM помечал соединение сломанным и **прекращал доставлять `posted`**, при этом **сокет не закрывал**. Поэтому `mattermost websocket connected` в логе остаётся, а событий нет. Особенно коварно после снятия челленджа: счётчик `seq` пропадал вместе с ним, и **ни один** исходящий фрейм его не нёс. **Фикс** (`deltas-mattermost-16-seq.json`): поле `seq int64`, `nextSeq()` под мьютексом, единый `writeEvent(conn, action, data)`, сброс `seq = 0` при новом соединении, пинг через `writeEvent`, а фрейм со `status:FAIL` логируется **warn**, а не игнорируется. 📌 **Правило:** любая отправка в MM-сокет — **только** через `writeEvent`. «Сырой» `conn.WriteJSON`/`WriteMessage` без `seq` = тихо убитая подписка. 📌 **Общий урок:** лог «connection established» не значит, что подписка жива. Проверять **фактом получения события**, а не фактом коннекта. Временный лог каждого кадра окупается сразу — и его стоит оставить после фикса. --- ## 🔴 Тест-ассерт — это гипотеза, а не истина (PR-проход 2026-09-26) Дважды за сессию падал **новый** тест, и оба раза неверным оказывался **ассерт**, а не код. Соблазн «поправить код под тест» — прямой путь сломать работающее. | Падающий тест | Моя гипотеза | Реальность | |---|---|---| | «тред и корневой пост дают одну сессию» | тред — часть канальной сессии | тред — **отдельная** сессия: корневой пост = `mm-c-a4aa02ef`, тред = `mm-t-89bbf64f`; при этом два **разных** ответа в треде дают ОДНУ сессию `mm-t-89bbf64f` — это и есть контракт | | «`NewIngress` фолбэчит `botUserID` из клиента» | есть fallback | `onStart` **отказывается стартовать** без `bot_user_id` (guard в ingress.go) | **Процедура при падении нового теста:** 1. Написать одноразовый пробник, печатающий **фактическое** поведение (`t.Logf` всех вариантов), а не утверждающий желаемое. 2. Сверить с комментарием/доком функции — там часто уже написано, чему код **обязан** соответствовать. 3. Только потом решать: ассерт или код. ⚠️ Пробник класть в пакет и **удалять** после (`rm mm_locator_probe_test.go`) — иначе мусор в PR. ## 🔴 `goconst` / `dupword` в новых тестах `golangci-lint` валит даже тестовые файлы. На MM-тестах поймано 4 замечания, все — в моём новом коде: | Линтер | Что | Фикс | |---|---|---| | `goconst` | `"channel-1"` — 15 вхождений, `"user-1"` — 3, `"dm-channel-1"` — 3 | константы `testTeamID`/`testChannelID`/`testDMChannelID`/`testUserID` в пакете | | `dupword` | `"thread channel = %q, channel channel = %q"` — повтор слова `channel` | переформулировать: «thread and channel locators resolve to different channels» | 📌 Линтер **подсказывает** имя существующей константы: `string 'channel-1' has 4 occurrences, but such constant 'testChannelID' already exists`. Заводить константы сразу при написании тестов дешевле, чем добивать потом. ## 📌 Артефакты деплоя — вон из PR `tools/mm-*.sh` (9 ops-скриптов) и `tools/deltas-*.json` — **локальный инструментарий**, в PR в апстрим не идут. В коммиты PR шли только: код транспорта, wiring, тесты, доки. ⚠️ **Поправка (та же сессия, позже):** я сначала написал, что `tools/` «оставлены незакоммиченными». Это было **неверно** — проверка показала, что **16 файлов `tools/` уже сидят в базовом коммите `004fda2f`** (из прошлой сессии), и в апстриме каталога `tools/` **нет вообще** (`git ls-tree upstream/main tools/` → пусто). То есть в PR уезжал мусор, а `git status` этого не показывал. ## 🔴 Проверять СОСТАВ PR, а не рабочее дерево **Правило:** перед объявлением «готово к PR» смотреть содержимое коммитов, а не только `git status`: ```bash git diff --stat upstream/main...HEAD # ЧТО реально уедет в PR git ls-tree --name-only upstream/main # есть ли этот dir в апстриме вообще git log --oneline --name-only ^..HEAD -- '/' # не моё ли ``` Три команды отвечают на три разных вопроса, и **`git status` не отвечает ни на один**: он показывает рабочее дерево, а не то, что уже закоммичено ранее (в т.ч. чужой рукой). 📌 **Симптом промаха:** «я всё вычистил, PR готов» → `git diff --stat` показывает 56 файлов вместо 40. Лишние пришли из **предыдущей** сессии, не из текущей. 📌 **Полезное следствие:** `git log --name-only -- '/'` точно отделяет **свой** мусор от **унаследованного** — без этого легко начать «чинить» не то. ## ⚠️ `git rm --cached` требует апрува — не повторять вслепую `git rm -r --cached tools/` идёт на подтверждение; при таймауте **не применяется** (безопасно — состояние целое, проверять `git status --short`). Не ретраить автоматически: удаление из индекса — операция, требующая команды заказчика. Альтернатива без «удаления»: оставить файлы untracked, а из индекса убрать одним явным коммитом по команде Alex. ## 🔴 PR-гигиена: проверять состав ветки, а не свои коммиты База ветки `004fda2f` (коммит **прошлой сессии**) затащила в PR **16 файлов `tools/`** (`apply-project-deltas.py`, `run-project-deltas.sh`, `deltas-mattermost*.json`, `.deltas-state.json`). В апстриме каталога `tools/` **нет вообще** — проверка: `git ls-tree upstream/main tools/` → пусто. 🔴 **Свои коммиты были чистыми**, но ветка — нет: ```bash git log --oneline --name-only upstream/main..HEAD -- 'tools/' # → показал базу, не мои коммиты ``` Поэтому смотреть надо **состав PR**, а не свои коммиты: ```bash git diff --name-only upstream/main...HEAD # все файлы PR git diff --name-only upstream/main...HEAD | grep -v '^tools/' # что должно остаться ``` **Фикс — два шага, оба обязательны:** ```bash echo '/tools/' >> .gitignore git rm -r --cached tools/ # 🔴 файлы ОСТАЮТСЯ на диске, уходят только из git ``` ⚠️ **`gitignore` НЕ отвязывает уже отслеживаемые файлы.** Без `git rm --cached` файлы останутся в индексе навсегда, сколько ни игнорь их. ⚠️ `git rm -r --cached` уходит на **апрув** (рекурсивное удаление) — если апрув истёк, команда **не выполнилась**, состояние целое; проверить `git diff --cached --name-only` до повтора. ⚠️ Артефакты деплоя (`mm-*.sh` ops-скрипты) в PR не шлём — только код транспорта, wiring, тесты, доки. ## 🔴 Целевой репозиторий определять через API, не по имени remote'а Два remote'а выглядели так: `origin` = `mallexxx/balda` (форк), `upstream` = `normahq/balda`. Но **реальный родитель форка — `baldaworks/balda`**, а `normahq/balda` — просто редирект: ```bash gh repo view mallexxx/balda --json parent # → parent: baldaworks/balda gh repo view normahq/balda --json owner # → owner.id: O_kgDOEgAHrw ← тот же org, что у baldaworks/balda = редирект, не отдельный репо ``` 📌 Создавать PR в **`baldaworks/balda`**, а `--head` указывать как `mallexxx:` («владелец:ветка»). Полный URL форка в `--head` ломает `gh`. ```bash gh pr create --repo baldaworks/balda --base main \ --head mallexxx:feat/mattermost-channel \ --title "…" --body-file /tmp/pr-body.md ``` ⚠️ **Проверять базу перед пушем:** `git rev-list --left-right --count upstream/main...HEAD` → `0 7` значит «0 позади, 7 впереди» — идеальная база, rebase не нужен. ## 🔴 CI на чистом раннере — это НАСТОЯЩАЯ приёмка сборки (шире локальных гейтов) Локально `cmd/balda` падал на `TestInitCommand_NonInteractiveAutoSelectsRootAndGeneratesDetectedAgents`. В CI (чистый раннер, свой образ) **тот же тест прошёл**. Локальные гейты (`build`/`vet`/`golangci-lint`/`go-arch-lint`/`go test`) — **необходимое, но не достаточное** условие. Они видят состояние **моей** машины: остатки кэшей, локальные конфиги, переменные окружения, версии инструментов. CI видит чистое дерево. **Пайплайн в `baldaworks/balda` (8 джобов):** `architecture-lint`, `lint`, `test`, `ingress-e2e`, `jetstream-integration`, `postgres-integration`, `sqlite-integration`, `projection-replay`. 📌 **Порядок:** локальные гейты → push → **дождаться CI** → только потом говорить «готово». Не объявлять готовность по локальным гейтам, если в репо есть CI. ⚠️ Локальный фейл, которого нет в CI, — **не повод чинить код**: сначала проверить, не особенность ли это локальной среды (`git stash` + повтор, или прогон в контейнере). ## Связанные заметки - [[personal/projects/balda/balda-mattermost-connector]] — Фаза 2, где всё это найдено - [[personal/projects/balda/balda-kraken-mm-deploy]] — Фаза 3, деплой на Kraken - [[personal/plans/balda-mm-endtoend-plan]] — сквозной путь `@balda` → ответ - [[personal/projects/balda/balda-mm-delegation-plan]] — общий план - [[personal/tech/hermes-secret-redaction-bypass]] — маскировка секретов в tool-выводе --- ## Диаграмма потока ингресса (для отладки нового транспорта) ``` MM websocket frame ↓ readLoop (frame.messageType == TextMessage?) ↓ handleFrame ← лог «event decoded»; status:FAIL → warn ↓ dispatchPosted (IsBotEcho / IsSystemPost / IsDeletedPost → drop с логом) ↓ processPosted (MentionsBot / StripMention → drop с логом) ↓ InboundProcessor.ProcessInbound ← 🔴 ТУТ был nil (Дефект 5) ↓ ingressapp.Service.Process ├─ Authorizer.Authorize ← subject + canonicalSubject (Дефекты 3, 7) ├─ SessionPreparer.Prepare ← GetSession → RestoreSession → EnsureSession └─ Dispatcher.Dispatch ← durable envelope ↓ turn_dispatcher → provider (OPENAI_BASE_URL + CA, Дефект F) ``` **Точки отказа, которые надо проверять по порядку** — сверху вниз, по логам: `frame received` → `event decoded` → `posted event accepted` → `post accepted for processing` → `conversational ingress settled` (`settlement_stage`!) → `assembled provider user content` → `turn task failed`. Пока нет строки `assembled provider user content` — провайдер не виноват. --- ## 🔴🔴🔴 Патч tool_calls ОБНАЖИЛ двойную паковку инструментов (2026-09-27) **Симптом.** После деплоя `balda-mm:0.1.12` (форк `runtime` с поддержкой `tool_calls`) **каждый** ход падает, Балда молчит: ``` 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 в [[personal/plans/balda-mm-phase4-agents]]) — но конфиг **чист** (`balda.mcp_servers: []`, ID только у провайдера). Значит причина другая, и правкой конфига она не лечится. ### Ключевой инсайт: `TurnComplete: true` МАСКИРОВАЛ баг | Образ | `TurnComplete` | Что происходило | |---|---|---| | `0.1.11` (апстримный `openai.go:107`) | жёстко `true` | агент завершался после **первого** ответа модели → инструменты **не пакались вообще** → `duplicate tool` не возникал, **но и инструменты не работали** | | `0.1.12` (форк) | `!hasToolCalls` | при `tool_calls` агент идёт на **второй раунд** → начинается паковка инструментов → всплывает двойная регистрация | > 📌 **Формулировка для будущих сессий:** форк **не регрессия — форк диагност.** Ошибка жила > в связке Balda↔ADK всё время; не было только пути её вызвать. Прежде чем объявлять новый > патч виновником — проверить, не **обнажил** ли он dormant-баг. ### Механика: два независимых пути паковки в один `req.Tools` `google.golang.org/adk/v2@v2.4.0`: ``` base_flow.go:778 toolPreprocess() — пакует agent.Tools → req.Tools base_flow.go:798 toolsetPreprocess() — пакует agent.Toolsets → req.Tools └─ mcptoolset/tool.go:89 → toolutils.PackTool() toolutils.go:47-48: if _, ok := req.Tools[name]; ok { return fmt.Errorf("duplicate tool: %q", name) } ``` - `tools_processor.go:31` — `if f.Tools != nil { return }` — защищает **только** сборку `f.Tools`, **не** повторный вызов `toolsetPreprocess`. - `PackTool` **идемпотентным не является by design** — дублировать источник нельзя. - Дубль возникает, если инструмент присутствует **и в `Tools`, и в одном из `Toolsets`**, либо в двух toolsets. Кандидат в нашем случае: `agentfactory.go:655-656` кладёт в `llmagent.Config` одновременно `Tools: req.Tools` и `Toolsets: toolsets` (bundled `balda` мог попасть в оба). 📌 `grep -rn "duplicate tool"` по репозиторию Balda **пуст** — искать в ADK (`$(go env GOMODCACHE)/google.golang.org/adk/v2@v2.4.0/tool/toolutils/toolutils.go`). ### Что исключено фактами (не проверять снова) | Версия | Проверка | Итог | |---|---|---| | Конфиг дублирует MCP-ID | `balda.mcp_servers: []`, ID у провайдера | ❌ чисто | | Сессия накопила мусор | `/reset` **от владельца** (admin PAT) | ❌ не лечит | | Форк не попал в образ | `strings` бинарника из `0.1.12` | ❌ форк на месте | | Каталог `state.db` замусорен | уникальные ID: `builtin/balda`, `configured-mcp/executor-mcp`, `configured-mcp/obsidian` — по одному | ❌ по одной записи | | Bundled MCP стартует дважды | лог `bundled MCP listener started` — один раз | ❌ однократно | | Патч трогает сборку инструментов | `git diff v2.0.11 --stat` → только `openai.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. ### Метод: доказать, что код патча попал в образ (`scratch`, shell нет) ```bash docker save balda-mm:0.1.12 -o /tmp/chk.tar mkdir -p /tmp/chkx && tar -xf /tmp/chk.tar -C /tmp/chkx # OCI-формат: слои в blobs/sha256/*, бинарник — в самом большом слое for f in /tmp/chkx/blobs/sha256/*; do file -b "$f" | grep -q gzip && tar -xzf "$f" -C /tmp/chkx 2>/dev/null done strings /tmp/chkx/usr/local/bin/balda | grep -E "tool_call_id|missing content and tool_calls" ``` ⚠️ `tar -tf image.tar | grep ca-certificates` на OCI-образе даёт **ложный 0** — файлы внутри слоёв. То же и с поиском кода патча: смотреть только распакованный бинарник. 🔴 **Кэш сборки — реальный риск, но не засорение диска.** `COPY runtime-local/` мог подставить **старые исходники** без ошибки. В этот раз обошлось, потому что изменились `go.mod` и `Dockerfile.release` (слои инвалидировались сами). Если менять **только содержимое** `runtime-local/` — брать `--no-cache`, иначе тихо соберутся старые исходники. ### Побочный эффект откатов: долгий старт из-за WAL Жёсткое убийство контейнера оставляет незакрытый `state.db-wal`. Следующий старт на `0.1.11` идёт **~4.5 мин** вместо ~1m11s (переигрывание WAL). **Это не зависание:** `docker logs | wc -l` растёт рывками, процесс висит в `do_epoll_wait` — живой. Признак настоящего зависания — рост лога остановился **и** нет ни одного потока в `epoll`. 📌 Метод чтения состояния застрявшего процесса в контейнере без shell: ```bash PID=$(docker inspect balda-mm --format '{{.State.Pid}}') for t in $(sudo ls /proc/$PID/task); do echo -n "$t: "; sudo cat /proc/$PID/task/$t/wchan; echo; done ``` `do_epoll_wait` = слушает · `blk_mq_get_tag` = дисковый I/O · `futex_wait_queue` = ждёт мьютекс/канал.