# Balda — Mattermost connector: PR-подготовка > Обновлён 2026-09-28. Обновлён 2026-09-29 (ревизия: PR СМЕРЖЕН, ветки снесены). > Статус: **✅ PR #8 СМЕРЖЕН 2026-09-29T06:06:48Z — `bf4a6ae4`.** MM-транспорт в `upstream/main`. > https://github.com/baldaworks/balda/pull/8 > 🔧 **2026-09-28: правки по ревью применены — голова `05342c5a` (9 коммитов), CI 8/8 PASS.** > Ревью `metalagman` опубликовано в PR (`reviews`, state `COMMENTED`, inline-комментов **0**). > Закрыты: slash-команды (HTTP-приёмник + setup в доке), `/locator` (`locatorref` + > guard-тест), групповой DM (сессия по чату + тест двух отправителей) + **регистрация в > `ScopeResolver`, которой не было в PR**. Сверка построчно — > [[personal/plans/balda-mm-pr8-review-fixes]] §0a. > 📌 **Мерж прошёл без `APPROVED`-ревью** — `reviewDecision` оставался пустым, PR влит вручную > мейнтейнером. Мерж-коммит `bf4a6ae4` = merge `86643007` (голова PR, 29 коммитов, > 72 файла, +7921/−74) в `93c7ac89` (`upstream/main` на тот момент). > Часть [[personal/projects/balda/balda-mm-delegation-plan]] (Фаза 2 → PR в апстрим). --- ## 0. ✅ ПОСТ-МЕРЖ: git-гигиена (2026-09-29) Ветки снесены, prune выполнен, worktree почищены, `/tmp` и форки `norma` вычищены. Полный разбор — [[personal/plans/balda-runtime-rebuild-mm-validation]] блок «2026-09-29». ```bash cd ~/Developer/balda git checkout main && git pull --no-rebase origin main git branch -D feat/mattermost-channel feat/zulip-transport feat/zulip-transport-intermediate \ backup/our-main-before-upstream feat/zulip-events-polling git push origin --delete feat/mattermost-channel feat/zulip-transport git remote prune origin && git remote prune upstream # ⚠️ СНАЧАЛА worktree, потом ветки — иначе branch -D падает git worktree remove --force <11 путей> && git worktree prune git branch -D codex/mattermost-prod-fix rm -rf ~/Developer/balda/personal rm -rf /tmp/balda-* /private/tmp/balda-* # ~130 файлов rm -rf ~/Developer/norma-local ~/Developer/norma-fork ~/Developer/norma-upstream-check # 291 МБ ``` **Итог: `~/Developer/balda` = только `main`, один worktree, чистое дерево** (кроме `.gitignore`). 🔴 **Питфолл: `git worktree list` показывал 10 несуществующих путей** `/private/tmp/balda-mm-pr8-*`. Каталоги снесены из `/tmp`, но `.git/worktrees/` живы и **держат ветку**: `git branch -D` → `error: cannot delete branch 'X' used by worktree at '/path'`. Порядок обязателен: `worktree remove --force` → `worktree prune` → `branch -D`. 🔴 **Питфолл при сносе форков `norma`:** в `~/Developer/norma-fork` лежал **непушенный** коммит `70ff876 feat: support custom OpenAI base URL via OPENAI_BASE_URL env var` (ветка `feat/custom-openai-base-url`). Это тот самый `OPENAI_BASE_URL`, на котором держится запуск Балды. Проверять `git log --branches --not --remotes --oneline` **перед** `rm -rf` любого репо. 📌 **`.gitignore` изменён (не закоммичен):** добавлены `/personal/` и `/tools/` — доки Obsidian дважды попадали в корень репо из-за `write_file` с vault-относительным путём при cwd = репо. 📌 **`~/Developer/balda/personal/` был дубликатом вотла, не уникальным контентом** — обе версии в `~/obsidian` новее по размеру и дате. Сверять перед сносом, а не удалять по имени файла. ⚠️ **Локальная `main` отстала от `upstream/main`.** MM-транспорт ушёл в **апстрим**, не в `origin/mallexxx/balda`, поэтому `git pull origin main` → `Already up to date` — это не «всё чисто», а «origin не содержит мержа». Догонять через `git merge upstream/main` (не rebase — `AGENTS.md`). Решение по этому шагу **не принято**. ## 1. Состояние (актуально на 2026-09-29) | | | |---|---| | PR | **baldaworks/balda#8** — `feat(mattermost): add Mattermost channel transport` | | Состояние | ✅ **MERGED** 2026-09-29T06:06:48Z · merge-commit **`bf4a6ae4`** | | База / голова | `baldaworks/balda:main` ← `mallexxx:feat/mattermost-channel` @ `86643007` | | Объём на момент мержа | **72 файла, +7921 / −74**, 29 коммитов | | Ревью | `COMMENTED` от `metalagman`, `APPROVED` не было — мерж вручную | | Ветка в форке | 🗑️ удалена локально и в `origin` 2026-09-29 | ``` architecture-lint pass 2m2s ingress-e2e pass 30s jetstream-integration pass 1m0s lint pass 2m46s postgres-integration pass 57s projection-replay pass 26s sqlite-integration pass 1m15s test pass 6m0s ← главный: полный go test на чистом раннере ``` **Ревью состоялось 2026-09-28** (`metalagman`, state `COMMENTED`) — правки применены, голова `05342c5a`, CI 8/8 на новом коммите. См. §0a [[personal/plans/balda-mm-pr8-review-fixes]]. 🔴 **Блокер: `reviewDecision` пустой = ревью было COMMENTED, а не APPROVED.** Мержа не будет, пока ревьюер не сменит состояние на approval. Наш ход — после правок дождаться ре-ревью; периодически перепроверять `gh pr view 8 --repo baldaworks/balda --json reviewDecision,reviews`. 🔴 **Целевой репозиторий — `baldaworks/balda`, НЕ `normahq/balda`.** Проверено через API, не по названию remote'а: ``` gh repo view mallexxx/balda --json parent → parent: baldaworks/balda ← форк от него gh repo view normahq/balda --json owner → owner.id: O_kgDOEgAHrw (= baldaworks) ← редирект, не отдельный репо ``` `origin` = `mallexxx/balda` (форк), `upstream` = `normahq/balda` (редирект на baldaworks). PR создавать **в `baldaworks/balda`**, head указывать как `mallexxx:` — не полный URL форка, иначе `gh` путается. --- ## 2. Боевая приёмка — ПРОЙДЕНА 2026-09-26 (образ `0.1.10`, коммит `9f1e6c9c`) Сквозной путь работает **от аккаунта Alex**. Это первая настоящая приёмка — прошлая была ложной (от `testuser`). ### Доказательство — лента канала `balda-test` ``` 1790413414689 | edxrgupoxiyig5gzmr79tjypbme | слово ← ОТВЕТ БОТА 1790413409055 | cbwsorzwoigbtrdnteugnx4aqo | @balda скажи одно слово ← пост ALEX ``` Запрос — `cbwsorzwoigbtrdnteugnx4aqo` (**аккаунт Alex**, `admin`), ответ — бот. В логах: **`settlement_stage=accepted`** (НЕ `unauthorized`), `assembled provider user content`, `delivery settled`. ### Привязка владельца — починена ``` balda_user_bindings: channel_type = mattermost principal = cbwsorzwoigbtrdnteugnx4aqo ← БЕЗ префикса ✅ (иначе unauthorized) display_name = mattermost:cbwsorzwoigbtrdnteugnx4aqo ← ОДИНАРНЫЙ префикс ✅ (нет мусора) ``` Дефект двойного префикса вылечен **в коде** (идемпотентный `MattermostSubject`), в БД мусора нет. ### WS-здоровье и старт | Метрика | Значение | |---|---| | `bad_seq` | **0** ✅ | | `Invalid sequence` | **0** ✅ | | `pong` | **6** (пинг раз в 30 с работает) | | `RestartCount` | **0** ✅ | | Контейнер | `balda-mm:0.1.10` Up стабильно | ``` mattermost transport configured server_url=http://mattermost:8065 bot_user_id_set=true session manager ready balda_provider=deepseek mattermost websocket connected ws://mattermost:8065/api/v4/websocket Balda bot started. ``` Никакого crash-loop, `advertised commands have no actor handler` отсутствует. ### Два не-блокера 1. **`warn: getMe response missing result`** при старте — косметика: startup-лог не смог прочитать identity бота. На работу НЕ влияет (`bot_user_id_set=true` из конфига). Low priority, чинить в апстриме. 2. **`transport_user_id = c6udeze8dfrdxm5jbgq55bd58r`** в логах сессии — это `testuser`, id прошлого владельца, зашитый в persisted-метаданные сессии `mm-c-7aafea13` (создана в прошлой сессии от testuser). Авторизация прошла верно (по `principal` из bindings), ответ ушёл Alex'у, **но сессия хранит чужой `user_id`**. Чистится `/reset` в канале. На работу не влияет (проверено: ответ пришёл). --- ## 3. Сборка и деплой (воспроизводимо) ```bash # 1. образ из PR-коммита (на Mac) cd ~/Developer/balda docker buildx build --builder arm64builder --platform linux/arm64 \ -f Dockerfile.release --build-arg VERSION=v0.3.1-mm-pr8 \ --build-arg COMMIT=$(git rev-parse --short HEAD) \ --build-arg BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) \ --provenance=false --load -t balda-mm:0.1.10 . # 2. перенос (распаковка zstd НА MAC — на хосте zstd 1.5.4 не читает фрейм macOS) docker save balda-mm:0.1.10 -o /tmp/balda-0110.tar zstd -3 -T0 -o /tmp/balda-0110.tar.zst --force /tmp/balda-0110.tar zstd -dc /tmp/balda-0110.tar.zst | ssh kraken 'docker load' # 3. переключение с бэкапом compose ssh kraken "cat > /tmp/mm-switch-image.sh" < tools/mm-switch-image.sh ssh kraken 'bash -n /tmp/mm-switch-image.sh' < /dev/null # SYNTAX_OK ssh kraken 'bash /tmp/mm-switch-image.sh' < /dev/null ``` | Скрипт | Что делает | |---|---| | `tools/mm-switch-image.sh` | смена тега образа в compose + бэкап + `--force-recreate` | | `tools/mm-whoami.sh` | какой MM-аккаунт у PAT (`/users/me`) — нужен для приёмки от заказчика | ⚠️ **Проверка CA в образе:** `docker save` даёт **OCI**-формат, files в `blobs/sha256/*`; `tar -tf image.tar | grep ca-certificates` даст **0** (ложный минус). Проверять по слоям или верить `docker inspect` → `SSL_CERT_FILE` в `Config.Env`. ⚠️ **Приёмка обязывает иметь PAT заказчика.** `/tmp/admin-tok` = PAT Alex (`cbwsorzwoigbtrdnteugnx4aqo`, `admin`) — сверено через `mm-whoami.sh` **до** постинга. Без этого тест снова был бы от `testuser` = ложная приёмка. --- ## 4. Что в PR ### Коммиты (7) ``` 9f1e6c9c chore: ignore local project-editing tooling 58f27120 docs(mattermost): document the transport setup and behavior 80d4f41d test(mattermost): cover the transport, the ingress and the handler c7f18654 fix(mattermost): send a monotonic seq on every client frame 6dc3077a feat(mattermost): add the inbound handler and wire it into DI fa1a2855 feat(auth): support the Mattermost channel subject 004fda2f feat(mattermost): add Mattermost transport adapter ← база прошлой сессии ``` База ветки идеальная: `git rev-list --left-right --count upstream/main...HEAD` → `0 7` (0 позади, 7 впереди), `merge-base` == `upstream/main` == `de273d8f`. Rebase не потребовался. ### Гейты — все зелёные на закоммиченном дереве ``` go build ./... → exit 0 go vet ./... → exit 0 go tool golangci-lint run → 0 issues go tool go-arch-lint check --project-path . → OK - No warnings found go test -race (затронутые пакеты) → все ok тесты mattermost + handlersfx (verbose) → 126 PASS, 0 FAIL, 0 SKIP ``` 📌 **Настоящая приёмка сборки — CI на чистом раннере.** Локально `cmd/balda` падал на `TestInitCommand_NonInteractiveAutoSelectsRootAndGeneratesDetectedAgents` — в CI он **прошёл**, то есть падение было особенностью локальной среды, а не дефектом. Локальные гейты — необходимое, но не достаточное условие; ждать CI обязательно. ### Тесты — 103 добавлено (`grep -c '^func Test'`) | Файл | Тестов | Покрытие | |---|---|---| | `channel/mattermost/ingress_test.go` | 23 | 🔴 `bad_seq`-регрессия, монотонность и уникальность `seq` под 16 горутинами, фильтры, panic-recovery, семафор | | `channel/mattermost/inbound_test.go` | 18 | `NormalizeInbound`, mention-фильтр, `ParseCommand`, whitelist команд, `PostIDFromWebSocket` | | `channel/mattermost/adapter_test.go` | 17 | все `deliverycmd.Operation`, plain-fallback только на content-rejection, draft-as-post, typing no-op без сети, `root_id` в треде | | `handlersfx/mattermost_ingress_test.go` | 17 | публикация всех команд, **одиночный** префикс principal, `/start` до авторизации, отказ чужим, nil-safety | | `channel/mattermost/locator_codec_test.go` | 15 | локаторы, thread↔channel изоляция, `LocatorFromAddressKey`, scope, UserID | | `channel/mattermost/client_test.go` | 13 | ws/wss derivation, `Bearer`, `APIError`, `isContentRejectedError`, контекст | ### Что починено по существу 1. **Дефект двойного префикса — в коде, а не в БД.** Прошлый фикс был прямым `UPDATE` в `state.db`, поэтому дефект вернулся. Теперь `auth.MattermostSubject()` идемпотентен (срезает уже имеющийся префикс `mattermost:` перед повторной квалификацией), а из `actors/command/start/handler.go` убран локальный `TrimPrefix`-костыль. Почему ломало прод: `canonicalSubject` режет subject по **первому** `:`, а `GetUserByBinding` сравнивает **точно** → `mattermost:mattermost:` не совпадает ни с чем → каждый пост Alex отбивался `settlement_stage=unauthorized`. Регрессия: `TestMattermostSubjectIsIdempotent`, `TestMattermostHandlerSendsChannelQualifiedPrincipalExactlyOnce`. 2. **Противоречие `ProgressPolicy.Typing`.** Command-путь ставил `Typing: true`, inbound-путь — `false`, а `SendTyping` у MM-адаптера осознанный no-op (у ботов MM нет typing API). Мёртвая ветка + warn-шум на каждом ходу → приведено к `false`. 3. **Доки** — `README.md`, `docs/balda.md`, новый `docs/mattermost.md` (создание бота, минтинг токена — отдаётся ОДИН раз, membership в команде/канале, 5 ключей конфига, поведение в DM/канале/треде, troubleshooting `bad_seq`/`unauthorized`/`x509`). ### Предсуществующие падения — НЕ наши (проверено `git stash`) 5 пакетов: `cmd/balda`, `apps/balda`, `mcpruntime`, `session`, `state`. Те же 4 теста падают **без** наших правок: `TestInitCommand_NonInteractiveAutoSelectsRootAndGeneratesDetectedAgents`, `TestCanonicalSessionMemoryRuntimeComposesPortableCapabilities`, `TestCanonicalSessionMemoryRuntimeMigratesAndReopensGroupedStore`, `TestResolveWorkspaceBaseBranch_ConfigPreferredWhenValid`. ### Команды PR (воспроизводимо) ```bash cd ~/Developer/balda git fetch upstream --quiet git rev-list --left-right --count upstream/main...HEAD # 0 7 git push -u origin feat/mattermost-channel gh pr create --repo baldaworks/balda --base main \ --head mallexxx:feat/mattermost-channel \ --title "feat(mattermost): add Mattermost channel transport" \ --body-file /tmp/mm-pr-body.md gh pr view 8 --repo baldaworks/balda --json state,mergeable,changedFiles,additions gh pr checks 8 --repo baldaworks/balda ``` --- ## 5. Пять файлов на диске — зачем они, НЕ трогать В `~/Developer/balda` пять файлов изменены относительно коммита `9f1e6c9c` и **в PR не входят**. Это **не фиксы** и **не потерянный код** — так и задумано. **Откуда взялось:** 2026-09-25 18:58 (Zulip `personal/Балда`, msg `116952`) ветка пересоздана от чистого апстрима: `git branch -f feat/mattermost-channel upstream/main` — «без мержа наших коммитов». WIP прошлых сессий остался висеть на диске. Работа продолжилась в этой же ветке и завершилась PR #8; существенное вошло в коммит `9f1e6c9c`. | Файл | Что это | В PR? | |---|---|---| | `go.mod` | `replace github.com/normahq/runtime/v2 => ./runtime-local` — потомок `5ad65955` | ❌ | | `Dockerfile.release` | `COPY runtime-local/` перед `go mod download` — иначе сборка падает | ❌ | | `go.sum` | −2 хэша runtime v2.0.11 под тот же replace | ❌ | | `internal/apps/balda/app.go` | WIP прошлых сессий (MM-импорты без псевдонима) — артефакт переключения ветки | ❌ | | `internal/apps/balda/preflight.go` | то же + `ClassifyLocatorScope` во вторую карту | ❌ | **Зачем они нужны:** без `runtime-local` стенд Balda на Kraken **не собирается**. `go.mod` берёт не апстримовый runtime, а локальный форк (592 KB) с патчем `tool_calls` для hostedagent (авторский PR `normahq/runtime#4`) — без него делегирование в CLI-агентов не работает. Остальные 4 файла обслуживают этот же replace: `Dockerfile.release` копирует папку в образ, `go.sum` больше не содержит хэшей апстримового runtime. **Почему не в PR:** `replace` на локальную папку ломает сборку у любого, кто склонирует `baldaworks/balda`. Такое апстрим не принимает. Место этому — в локальной ветке (`local/*`). **Решение (2026-09-28, Alex):** оставлено как есть, отдельная ветка не делалась. ⚠️ **Риск:** `git checkout -- .` / `git stash` / `git reset --hard` в `~/Developer/balda` снесёт эти 5 файлов. Перед любой такой командой — сверить `git status --short`. --- ## 6. Следующие шаги, чтобы «всё работало» — по приоритету | # | Шаг | Тип | |---|---|---| | 1 | Дождаться ревью #8 → правки в ту же ветку (push обновит PR) | ⛔ внешний блокер | | 2 | **Фаза 4 — исполнитель (делегирование)** — превращает «бот отвечает» в «бот работает» | 🎯 основной фронт | | 3 | Боевая перепроверка приёмки на Kraken (образ `0.1.10` примлен 26.09 — повторять только если что-то менялось) | ↩ по необходимости | | 4 | Замержить PR после ревью → Фаза 2 закрывается официально | ⏳ после 1 | 📌 **Пока PR висит на ревью — работать над Фазой 4 в ветке `local/*`, НЕ в `feat/mattermost-channel`.** Иначе новые коммиты уедут в открытый PR и обесценят пройденный CI. --- ## 7. Питфоллы, которые стоили времени 1. **`gitignore` НЕ отвязывает уже отслеживаемые файлы.** База `004fda2f` затащила в ветку **16 файлов `tools/`**, которых в апстриме нет вообще (`git ls-tree upstream/main tools/` → пусто). Найдено проверкой `git diff --stat upstream/main...HEAD`. Фикс (коммит `9f1e6c9c`): `/tools/` в `.gitignore` **плюс** `git rm -r --cached tools/` — файлы остались на диске (39 файлов), ушли только из git. Нужны оба шага, в этом порядке. ⚠️ `git rm -r --cached` уходит на апрув (рекурсивное удаление) — первая попытка истекла по таймауту и не применилась, состояние было целое. Проверять `git diff --cached --name-only`. 2. **Проверять состав PR через `git diff --stat upstream/main...HEAD`, а не по своим коммитам.** Свои 5 коммитов были чистыми (`git log upstream..HEAD -- tools/` → пусто), но ветка в целом — нет, из-за базового коммита. `git status` показывает рабочее дерево, не содержимое коммитов. 3. **Тест-ассерт — это гипотеза, а не истина.** Дважды падал тест, и оба раза неверным был ассерт, а не код: (а) «тред и корневой пост = одна сессия» — на самом деле тред **отдельная** сессия от канала (пробник: `mm-c-a4aa02ef` vs `mm-t-89bbf64f`); (b) «`NewIngress` фолбэчит `botUserID` из клиента» — на самом деле `onStart` отказывается стартовать без `bot_user_id`. Прежде чем править код под падающий тест — проверить, чему код **обязан** соответствовать. 4. **`goconst` в тестах** — повторяющиеся строковые литералы (`"channel-1"` ×15) валят линтер. Заводить константы (`testChannelID`) сразу. **`dupword`** — `"thread channel = %q, channel channel = %q"` пойман как повтор слова. 5. **`write_file` + `***` в доке** — плейсхолдеры вида `BALDA_MATTERMOST_TOKEN=***` в примерах конфига остаются на диске **целыми**; искажается только вывод чтения. Проверять `awk 'NR==N'`, не глазами (см. [[personal/tech/hermes-secret-redaction-bypass]]). 6. **⛔ zstd-фрейм macOS.** `zstd -d` на Kraken не читает фрейм, записанный на macOS (хостовый zstd 1.5.4). Распаковывать **на Mac**: `zstd -dc file.tar.zst | ssh kraken 'docker load'`. --- ## Связанные заметки - [[personal/projects/balda/balda-mattermost-connector]] — Фаза 2 (код коннектора) - [[personal/projects/balda/balda-kraken-mm-deploy]] — Фаза 3 (деплой, боевые причины тишины) - [[personal/projects/balda/balda-mm-delegation-plan]] — Фаза 4 (делегирование) - [[personal/tech/balda-go-fx-pitfalls]] — fx-питфоллы + дельта-апплаер - [[personal/plans/balda-mm-endtoend-plan]] — сквозной путь - [[personal/tech/hermes-secret-redaction-bypass]] — редакция секретов