20 KiB
Balda — Go/fx pitfalls и метод правок (зеркало скилла)
Создано 2026-09-26. Обновлено 2026-09-26 (ночь, позже) — добавлен Дефект 4 (advertisement ↔ actor-хендлер), найденный при первом реальном запуске на Kraken. Зеркало секции скилла
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) с включённым транспортом до объявления готовности.
⚠️ 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 с вложенными кавычками.
Связанные заметки
- personal/projects/balda/balda-mattermost-connector — Фаза 2, где всё это найдено
- personal/projects/balda/balda-mm-delegation-plan — общий план
- personal/tech/hermes-secret-redaction-bypass — маскировка секретов в tool-выводе