111 KiB
Executable File
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_memoryvsbalda.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 ST1023vsunconvert; решение «убрать конструкцию, а не выбирать сторону» + побочный неиспользуемый импорт. Там же проверенные гейты перед PR вruntimeи питфолл таймаутаgo test -race ./...(>600 с). Источник: работа над 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:
var asToolset tool.Toolset = ts // ← staticcheck ST1023: «omit type, it's inferred»
asToolset := tool.Toolset(ts) // ← unconvert: «unnecessary conversion»
Оба замечания валидны и взаимоисключающи — правка одного рождает другое. Цикл:
ST1023 → чинишь → unconvert → чинишь обратно → ST1023.
📌 Решение — не вводить переменную вообще. Проверять тип напрямую:
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 (проверенные)
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:
case transportMattermost:
return auth.MattermostSubject(strings.TrimPrefix(trimmed, transportMattermost+":"))
MattermostSubject() (auth/owner.go:307) сама добавляет префикс вида <channel>:<id>,
а p.Principal приходит от MM-хендлера уже с префиксом
(mattermost_ingress.go:147: Principal: auth.MattermostSubject(cmd.SenderID)).
📌 Правило: дефект, найденный в данных, искать и в коде. Правка данных без правки кода = дефект вернётся при следующей записи. Проверять оба слоя и фиксить источник.
✅ ИСПРАВЛЕНО 2026-09-26 (сессия PR-подготовки) — фикс в хелпере
Вместо костыля TrimPrefix в вызывающем коде сделана идемпотентной сама MattermostSubject:
// MattermostSubject returns the channel-qualified subject for a Mattermost user.
// It is idempotent: a value that is already channel-qualified ("mattermost:<id>")
// yields the same subject, so callers may pass either a raw user ID or a subject
// without risking a doubled prefix ("mattermost:mattermost:<id>").
func MattermostSubject(userID string) string {
trimmed := strings.TrimSpace(userID)
trimmed = strings.TrimPrefix(trimmed, ChannelMattermost+":")
return fmt.Sprintf("%s:%s", ChannelMattermost, trimmed)
}
userSubject вернулся к симметричному с другими транспортами виду:
case transportMattermost:
return auth.MattermostSubject(trimmed)
📌 Почему фикс в хелпере, а не в вызывающем: MattermostSubject вызывается из трёх
мест (userSubject, mattermost_ingress.go:147, authorizeInbound). Идемпотентность
закрывает все пути сразу и защищает будущие вызовы поверх готового subject'а.
Дельта tools/deltas-mattermost-23-prefix-idempotent.json.
Общий приём для нового транспорта: любой <X>Subject(id)-билдер делать
идемпотентным — срезать уже существующий <x>:-префикс. Тогда вызывающий код может
передавать и raw id, и готовый subject без риска двойного префикса. Это дешевле, чем
искать все call-site'ы, и переживает добавление новых.
Регресс-тесты: TestMattermostSubjectIsIdempotent (handlersfx/mattermost_ingress_test.go),
TestMattermostHandlerSendsChannelQualifiedPrincipalExactlyOnce.
⚠️ Тот же класс ошибки — двойное применение helper'а, который сам добавляет префикс.
Признак: строка вида <x>:<x>:<id> в БД, в 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 <name>
не подключён к сессии», в логе 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, но упал в рантайме» (и наоборот).
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
// 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.<id>.mcp_servers + balda.mcp_servers
🔴 Не перечислять ID серверов ДВАЖДЫ (balda.mcp_servers и
runtime.providers.<id>.mcp_servers) — эффективный набор это объединение, и каждый
сервер подключается дважды. Симптом — ход падает ещё до вызова модели:
error … "agent run: duplicate tool: \"balda.control.shutdown\""
→ в чате тишина; в логе `turn task failed` + `command deadlettered` (5 попыток)
Источник ошибки — библиотека ADK, не Балда:
// 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:
// :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-строку, а не объект. Разбор в два шага:
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):
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) |
Что работает
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-сети
# 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).
Ни одно не было регрессией от правки — те же падения на чистом дереве.
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 |
📌 Обязательные регресс-тесты для нового транспорта:
writeEventвсегда шлётseq(регрессияbad_seq).seqуникален под конкуренцией (16 горутин) — иначе MM увидит переупорядоченный кадр.<X>Subjectидемпотентен (регрессия двойного префикса).ProgressPolicyсовпадает на command- и turn-пути.- Fallback только на content-rejection (400), не на 5xx.
/startдоступен до авторизации.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-<hash(channel)>,
ответ в треде (RootID=<root>) → mm-t-<hash(channel|root)>. Тред — отдельная беседа
от канала. Соответствует комментарию в locator_codec.go, но означает: бот в треде ведёт
сессию, отдельную от канала. Зафиксировано тестом.
🔴🔴 Дефект 8: замкнутый круг привязки, когда binding занят чужим аккаунтом
Ситуация: владельцем привязан не тот аккаунт, а нужно привязать правильный. Все штатные пути оказываются закрыты.
| Штатный путь | Почему не работает |
|---|---|
/start owner=<owner_auth_token> |
HasOwner() = len(OwnerSubjects()) != 0. Владелец существует → код уходит в handleExistingOwnerState → IsOwner(<нужный аккаунт>) = false → «Bot owner is already registered» |
/start <channel_token> для нужного канала |
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 отсутствует.purposechannel-токена должен бытьowner_bind— токен из другого назначенияConsumeOwnerBindотвергнет.
🔴 Единственный выход без правки кода — UPDATE строки binding: меняем principal
(и display_name) на нужный аккаунт. Пользователь, роль и HasOwner() сохраняются,
связь «владелец ↔ аккаунт» переезжает. Обратимо обратным UPDATE.
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 автора:
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 отсутствует):
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. Проверять транспорт только реальным запуском валидации:
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.
Теги обязаны жить внутри структуры, снаружи аннотировать нельзя.
Неправильно:
fx.Annotate(NewIngress,
fx.ParamTags(``, ``, ``, `name:"balda_mattermost_enabled"`,
`name:"balda_mattermost_bot_user_id"`,
`name:"balda_mattermost_bot_username"`, ``),
),
Правильно:
// структура — теги на полях
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.
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. Транспорт требует оба импорта:
"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():
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 после фикса:
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_<X>_* переменные молча игнорируются.
Порядок для нового транспорта:
- блок в
cmd/balda/balda.yaml(иначе env мёртв) MattermostConfig-подобная структура + поле вBaldaConfig- провайдеры в
app.go validate<X>Config+ вызов рядом сvalidateSlackConfig- включить в
validateBaldaChannelConfiguration(cmd/balda/command.go) — иначе деплой только на этом транспорте отбивается «at least one channel is required» .go-arch-lint.yml— компоненты<x>_transport/<x>_wiring+ права уcomposition_rootиingress_wiring(см. §go-arch-lint выше)- тест на
validateBaldaApplicationс<x>.enabled=true(см. §«go build ≠ …» выше) - тест env-override в
cmd/balda/command_test.go(по образцуTestLoadConfigDocument_AppliesZulipEnvOverrides) - если добавлял маршрут в
BuiltinRoutes()— обновитьTestBuiltinRoutes - доки:
docs/reference/configuration.md(ключи),docs/commands.md(колонка транспорта) - 🔴 сверить
Advertisement.Namesс реальнымиName()вactors/command/*/handler.go— опечатка роняет весь fx-граф (см. §«Дефект 4» выше) - 🔴 живой запуск
balda start(илиdocker run) с включённым транспортом — единственная проверка, которая ловит дефект 4
Финальные quality gates (все обязательны, из AGENTS.md):
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:
type IngressParams struct {
fx.In
Processor mattermost.InboundProcessor `optional:"true"` // ← никто не предоставляет
…
}
В handlersfx/ были только zulip_ingress.go и telegram_ingress.go — MM-хендлера не
существовало. fx не падает на отсутствующей optional-зависимости, поэтому ингресс
получал Processor == nil и молча выходил:
if s.processor == nil {
return // ingress.go, processPosted
}
📌 optional:"true" — это утверждение «отсутствие допустимо», и его надо осознанно
подтверждать. Если порт обязателен для работы транспорта — маркер должен быть снят,
тогда fx упадёт на старте и дефект будет виден сразу.
Правило для нового транспорта: у каждого порта в *fx-модуле проверить, кто его
предоставляет. Если никто — транспорт не работает, даже если docker ps показывает Up.
Фикс: handlersfx/module.go —
fx.Annotate(
newMattermostInboundHandler,
fx.As(new(baldamattermost.InboundProcessor)),
),
(дельта tools/deltas-mattermost-19-wire.json).
🔴 auth: subject-based авторизация — новый транспорт надо регистрировать в трёх местах
Авторизация Balda проверяет subject вида <channel>:<principal> (telegram:101,
zulip:42, slackagent:<team>:<user>). Для нового транспорта это три отдельные правки,
и забытая любая из них даёт молчаливый отказ авторизации (не ошибку!):
| Файл | Что добавить | Симптом при пропуске |
|---|---|---|
auth/channel_token.go |
Channel<X> = "<name>" + в CreateMissingOwnerBindTokens |
транспорт не получает owner-bind токен |
auth/canonical_users.go |
ветку case Channel<X>: в canonicalSubject |
🔴 unsupported transport "<x>" → все IsOwnerSubject/GetCollaborator = false |
auth/owner.go |
функцию <X>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).
{"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):
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».
Правильный диагностический приём: временный лог каждого кадра в самом ингрессе:
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:
- Секция
components:— парыin:-компонентов:
# 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.
-
Правила
mayDependOn:mattermost_transport→foundation, contracts, neutral_registry, mattermost_transportmattermost_wiring→foundation, contracts, neutral_registry, application, delivery, storage, mattermost_transport, mattermost_wiring
-
composition_root→ добавитьmattermost_transport,mattermost_wiring(иначе «composition_root shouldn't depend on …» — это ожидаемый отказ, там перечислены все транспорты). -
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:
{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)
Метод проверки регрессии:
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-<topic>.json |
группы правок |
.deltas-state.json |
sha256 применённых якорей |
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
Формат дельты:
{ "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 <check|apply|list> <deltas.json> <state.json>.
Без третьего — 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:
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=<token> в DM получил от бота
«You are not allowed to use Balda in this channel.»
Причина: HandleCommand проверял доступ до обработки, а владельца ещё нет — тупик:
привязаться нельзя, потому что не авторизован; авторизоваться нельзя, потому что не привязан.
Владелец в canonical store привязывается этой же командой, поэтому она обязана быть
доступна неавторизованному.
Неправильно:
if !h.canAccess(ctx, cmd.SenderID) {
return h.sendPlain(ctx, cmd.Locator, accessDeniedText)
}
Правильно (образец — zulip_ingress.go:172):
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=<token> вернул успех («You are now registered as the bot
owner»), но каждое обращение к боту оставалось settlement_stage="unauthorized".
Механизм. Авторизация сверяет subject вида <channel>:<principal>. canonicalSubject()
разрезает его по первому : и отбрасывает префикс:
if before, after, ok := strings.Cut(trimmed, ":"); ok {
channelType = strings.ToLower(strings.TrimSpace(before))
principal = strings.TrimSpace(after) // ← префикс отрезан
}
А GetUserByBinding делает точное сравнение:
return s.getUser(ctx, `b.channel_type = ? AND b.principal = ?`, channelType, principal)
Если в БД записался principal = 'mattermost:<user_id>' при channel_type='mattermost',
то поиск по principal='<user_id>' не находит ничего — молча, без ошибки.
🔴 Правило: в balda_user_bindings principal хранится БЕЗ транспортного префикса.
Префикс живёт только в channel_type. Нельзя собирать subject тем же хелпером, которым
потом нормализуешь: userSubject() в start/handler.go оборачивает principal в
<transport>:<principal> — если на вход уже пришёл обёрнутый principal, выходит двойная
обёртка и запись уезжает с префиксом.
Диагностика — прочитать таблицу:
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 → старт):
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):
{"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 в схеме нет:
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:
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:
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
Рабочий порядок:
- Файлы состояния:
<state_dir>/state.db+state.db-wal+state.db-shm, владелец — root. sqlite3есть на хосте Kraken (/usr/bin/sqlite3), но не в контейнере.- Для остановленного контейнера — прямой
sqlite3подsudo -n. - Для живого —
strings(только просмотр, без записи).
🔴 Читать state.db ВМЕСТЕ с state.db-wal. Свежие записи живут в WAL, а основной файл
выглядит пустым. Прямая потеря времени: таблица balda_user_bindings казалась пустой, хотя
привязка владельца уже была записана — она лежала в -wal.
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=<token>;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 <dir>) уходит на апрув и блокируется по
таймауту. Точечный UPDATE одного поля такого апрува не требует — предпочтителен.
🔴 Mattermost WS: bad_seq — пинг без seq убивает подписку молча
Найдено 2026-09-26 диагностическим логом каждого кадра (deltas-mattermost-15-diag.json).
Это и была настоящая причина «connected, но тишина» — до неё ошибочно считалось, что
виноват authentication_challenge (он тоже вреден, но лишь как усугубляющий фактор).
MM отвечал раз в 30 секунд, ровно на каждый пинг:
{"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) |
Процедура при падении нового теста:
- Написать одноразовый пробник, печатающий фактическое поведение
(
t.Logfвсех вариантов), а не утверждающий желаемое. - Сверить с комментарием/доком функции — там часто уже написано, чему код обязан соответствовать.
- Только потом решать: ассерт или код.
⚠️ Пробник класть в пакет и удалять после (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:
git diff --stat upstream/main...HEAD # ЧТО реально уедет в PR
git ls-tree --name-only upstream/main <dir> # есть ли этот dir в апстриме вообще
git log --oneline --name-only <mой_первый_коммит>^..HEAD -- '<dir>/' # не моё ли
Три команды отвечают на три разных вопроса, и git status не отвечает ни на один:
он показывает рабочее дерево, а не то, что уже закоммичено ранее (в т.ч. чужой рукой).
📌 Симптом промаха: «я всё вычистил, PR готов» → git diff --stat показывает
56 файлов вместо 40. Лишние пришли из предыдущей сессии, не из текущей.
📌 Полезное следствие: git log --name-only <range> -- '<dir>/' точно отделяет
свой мусор от унаследованного — без этого легко начать «чинить» не то.
⚠️ 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/ → пусто.
🔴 Свои коммиты были чистыми, но ветка — нет:
git log --oneline --name-only upstream/main..HEAD -- 'tools/' # → показал базу, не мои коммиты
Поэтому смотреть надо состав PR, а не свои коммиты:
git diff --name-only upstream/main...HEAD # все файлы PR
git diff --name-only upstream/main...HEAD | grep -v '^tools/' # что должно остаться
Фикс — два шага, оба обязательны:
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 — просто редирект:
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:<branch>
(«владелец:ветка»). Полный URL форка в --head ломает gh.
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(bundledbaldaмог попасть в оба).
📌 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 нет)
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:
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 = ждёт мьютекс/канал.