26 KiB
Balda — Go/fx pitfalls и метод правок (зеркало скилла)
Создано 2026-09-26. Обновлено 2026-09-26 (ночь, root-cause сессия) — добавлены: WS-авторизация MM (§«авторизация — ОДИН механизм»),
Token:-заголовок мёртв в MM 11.x, формат JSON дельт (плоский массив + 3 аргумента у обёртки). Зеркало секции скиллаdevops/docker-balda-service, добавленной 2026-09-26. Источник: работа над Mattermost-адаптером Балды (Фаза 2) + деплой на Kraken (Фаза 3). Проектные доки: personal/projects/balda/balda-mattermost-connector, personal/projects/balda/balda-kraken-mm-deploy, personal/projects/balda/balda-mm-delegation-plan.
🔴 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/ревью.
🔴 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 — сначала проверить, каким именем
заголовка шлётся токен, а не искать проблему в пароле/лимите сессий.
Связанные заметки
- personal/projects/balda/balda-mattermost-connector — Фаза 2, где всё это найдено
- personal/projects/balda/balda-mm-delegation-plan — общий план
- personal/tech/hermes-secret-redaction-bypass — маскировка секретов в tool-выводе