1779 lines
112 KiB
Markdown
Executable File
1779 lines
112 KiB
Markdown
Executable File
# Balda — Go/fx pitfalls и метод правок (зеркало скилла)
|
||
|
||
> Создано 2026-09-26. Обновлено 2026-09-26 (сессия реализации MM-хендлера, полный проход;
|
||
> 🔴 **статус приёмки ИСПРАВЛЕН** — см. §«Ложная приёмка» и §«Чинить в коде, а не в данных»).
|
||
> Добавлены: **`bad_seq` — настоящая причина тишины WS** (§«WS-фрейм без seq»),
|
||
> **`optional:"true"` скрывает отсутствующую зависимость** (§«Дефект 5»),
|
||
> **`canonicalSubject` и неизвестный транспорт** (§«auth: subject-based»),
|
||
> **`/start` — тупик онбординга при проверке доступа** (§«Дефект 6»),
|
||
> **`base_url` в YAML мёртв — только `OPENAI_BASE_URL`** (§«Провайдер: …»),
|
||
> **`FROM scratch` без CA** (§«scratch и TLS»),
|
||
> **`principal` с префиксом в БД** (§«Дефект 7»),
|
||
> **`state.db` читать вместе с WAL** (§«Метод: чтение sqlite-состояния»),
|
||
> **🔴 ложная приёмка от чужого аккаунта** (§«Ложная приёмка»),
|
||
> **🔴 двойной префикс жив в коде, а не только в БД** (§«Чинить в коде»),
|
||
> **🔴 замкнутый круг привязки при занятом binding** (§«Дефект 8»).
|
||
> 🔴 **Новое в этой сессии:** §«Mattermost Team Edition: что недоступно» — push невозможен.
|
||
> Уточнён §«авторизация — ОДИН механизм»: челлендж НЕ был причиной №1, им был `bad_seq`.
|
||
> 🔴 **Добавлено 2026-09-26 (сессия MCP-фикса):** §«Scope-классификатор нового транспорта» —
|
||
> написать мало, надо зарегистрировать в ДВУХ картах (`app.go` + `preflight.go`); смежное —
|
||
> `balda.session_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` = ждёт мьютекс/канал.
|