Files
obsidian-vault/personal/tech/balda-go-fx-pitfalls.md
T

26 KiB
Raw Blame History

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>_* переменные молча игнорируются.

Порядок для нового транспорта:

  1. блок в cmd/balda/balda.yaml (иначе env мёртв)
  2. MattermostConfig-подобная структура + поле в BaldaConfig
  3. провайдеры в app.go
  4. validate<X>Config + вызов рядом с validateSlackConfig
  5. включить в validateBaldaChannelConfiguration (cmd/balda/command.go) — иначе деплой только на этом транспорте отбивается «at least one channel is required»
  6. .go-arch-lint.yml — компоненты <x>_transport/<x>_wiring + права у composition_root и ingress_wiring (см. §go-arch-lint выше)
  7. тест на validateBaldaApplication с <x>.enabled=true (см. §«go build ≠ …» выше)
  8. тест env-override в cmd/balda/command_test.go (по образцу TestLoadConfigDocument_AppliesZulipEnvOverrides)
  9. если добавлял маршрут в BuiltinRoutes() — обновить TestBuiltinRoutes
  10. доки: docs/reference/configuration.md (ключи), docs/commands.md (колонка транспорта)
  11. 🔴 сверить Advertisement.Names с реальными Name() в actors/command/*/handler.go — опечатка роняет весь fx-граф (см. §«Дефект 4» выше)
  12. 🔴 живой запуск balda start (или docker run) с включённым транспортом — единственная проверка, которая ловит дефект 4

Финальные quality gates (все обязательны, из AGENTS.md):

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:

  1. Секция 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.

  1. Правила mayDependOn:

    • mattermost_transport → foundation, contracts, neutral_registry, mattermost_transport
    • mattermost_wiring → foundation, contracts, neutral_registry, application, delivery, storage, mattermost_transport, mattermost_wiring
  2. composition_root → добавить mattermost_transport, mattermost_wiring (иначе «composition_root shouldn't depend on …» — это ожидаемый отказ, там перечислены все транспорты).

  3. 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 — сначала проверить, каким именем заголовка шлётся токен, а не искать проблему в пароле/лимите сессий.


Связанные заметки