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

1779 lines
112 KiB
Markdown
Executable File
Raw Blame History

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