> Создан 2026-09-28. Статус: **✅ MERGED В АПСТРИМ 2026-09-28T13:22:13Z (head `facb0d0`).** > Финальная голова PR: **`facb0d0`**. Мейнтейнер добавил **3 фикса поверх** моего `d4ab923` — > см. §0г. Образ Балды переведён на тег **`v2.1.0`** (= `c41939f`, содержит PR #4 + #5). > **PR normahq/runtime#5** — MERGED. Репо: `~/Developer/runtime-local`. > Связано: [[personal/plans/runtime-pr4-openai-toolcalls-fixes]], > [[personal/plans/balda-runtime-rebuild-mm-validation]]. > 🔴 **2026-09-29 — УТОЧНЕНИЕ ПО РЕПОЗИТОРИЮ.** PR #4/#5 (и #7) живут в **`normahq/runtime`**, > **НЕ** в `baldaworks/runtime` — такого репо не существует (`GraphQL: Could not resolve to a > Repository with the name 'baldaworks/runtime'`). Прежняя запись «репо `baldaworks/runtime`» — > ошибка. `baldaworks/` содержит только `balda`. > > 📌 **Ветки PR снесены 2026-09-29.** В `~/Developer/runtime-local` удалены локально и в > `origin`: `feat/openai-tool-calls-v2`, `fix/hosted-toolsets-duplicate-endpoint`, > `fix/openai-tool-call-roundtrip-v2` (PR #7). `fetch --all --prune` подтянул `upstream/main` > до `5375072` (PR #7) и тег **`v2.1.1`**. Остались `main` + `origin/main` + `upstream/main`. > > ⚠️ **`~/Developer/norma-local`, `norma-fork`, `norma-upstream-check` — это форки `norma`, > ДРУГОГО репозитория.** К PR #4/#5/#7 отношения не имеют, хотя названия сбивают. > `runtime-local` — единственный настоящий клон `runtime`. > > 🗑️ **СНЕСЕНЫ 2026-09-29** (291 МБ, решение Alex). ⚠️ Вместе с `norma-fork` потерян > **непушенный** коммит `70ff876 feat: support custom OpenAI base URL via OPENAI_BASE_URL env var` > (ветка `feat/custom-openai-base-url`, в remotes отсутствовал) + незакоммиченная правка > `norma-local/pkg/runtime/hostedagent/openai.go`. Это тот самый `OPENAI_BASE_URL`, на котором > держится запуск Балды (в YAML `base_url` мёртв — см. [[personal/tech/balda-go-fx-pitfalls]]). > **Восстанавливать неоткуда** — при необходимости писать заново. > 📌 Урок: перед `rm -rf` репо — `git log --branches --not --remotes --oneline`. --- ## 0г. 🔴 Мейнтейнер нашёл 2 реальных дефекта в моём фиксе (`d4ab923` → `facb0d0`) Перед мержем в ветку PR #5 добавлены 3 коммита: `f8011c1` fix(agentfactory): preserve MCP dedup metadata · `e888d5e` fix(agentconfig): validate preferred MCP dedup · `facb0d0` fix(agentfactory): keep MCP dedup silent. Diff: 5 файлов, +160/−10. | # | Дефект в моём фиксе | Фикс мейнтейнера | |---|---|---| | **1** | 🔴 **`hydrateMCPServerConfig` не переносил `DedupKey`/`DedupPreferred`** — собирал новый `MCPServerConfig` поле за полем и **забыл новые поля**. Фикс был бы **мёртв в проде** на пути `Build()` | добавлены `DedupKey: cfg.DedupKey`, `DedupPreferred: cfg.DedupPreferred`. Покрыто тестом `TestFactoryBuildKeepsPreferredDedupEndpoint` (сквозной путь registry → hydration → `hostedToolsets`) | | **2** | `strings.TrimSpace(key)` **нормализовал** ключ → независимо настроенные namespace'ы с пробелами схлопывались (ровно тот cross-tenant риск, который API и предотвращает) | сравнение по **сырому** ключу; проверка пустоты отдельно: `key := resolvedMCP[id].DedupKey; if strings.TrimSpace(key) == ""`. Тест `TestDedupKeyIsOpaque` | | **3** | Две записи с `DedupPreferred: true` в одной группе — **молча** выбиралась одна (зависит от порядка id) | явная ошибка: `mcp dedup key %q has multiple preferred configs`. Тест `TestHostedToolsetsRejectsMultipleDedupPreferredConfigs` | | **4** | `fmt.Printf("[mcp] skipping …")` в **библиотеке** — печать в stdout потребителя | вывод убран (коммит «keep MCP dedup silent») | | **5** | `DedupPreferred: true` без `DedupKey` не валидировался | `ValidateMCPServerConfig`: `dedup_preferred requires dedup_key`. Тест `TestValidateMCPServerConfig_DedupPreferredRequiresDedupKey` | 📌 **Уроки (durable):** 1. 🔴 **Добавляя поле в конфиг — грепнуть ВСЕ места ручного копирования структуры.** `hydrateMCPServerConfig` собирает `MCPServerConfig` поле за полем; компилятор такое **не ловит** (пропущенное поле = zero value, валидный код). Искать `grep -n "MCPServerConfig{"` во всём репо. Это ровно тот класс дефекта, что и «терпимый ассерт» / «копия логики в тесте»: **выглядит рабочим, молчит в проде**. 2. ⚠️ **Opaque = opaque.** Назвал ключ «непрозрачным» у вызывающего — не нормализуй его сам. `TrimSpace` выглядел гигиеной, а был дырой безопасности. 3. ⚠️ **Библиотека не печатает в stdout.** Логирование — дело потребителя. 4. 📌 **Тест сквозного пути ловит то, что юнит-тест уровня функции пропускает.** Мой тест дёргал `hostedToolsets` напрямую и не проходил через hydration — дефект №1 был невидим. Правильный тест — через `f.Build()`. --- ## 0а. 🔴 ПОСТ-МЕРЖ: мейнтейнер нашёл 2 дефекта в МОЁМ фиксе После моего `d4ab923` и **до** мержа в ветку прилетели 3 коммита мейнтейнера (`f8011c1`, `e888d5e`, `facb0d0` — head PR стал `facb0d0`). Это **code review моего кода**, и два замечания — настоящие дефекты: | Коммит | Что нашёл | Почему это мой дефект | |---|---|---| | `f8011c1` `fix(agentfactory): preserve MCP dedup metadata` | **`hydrateMCPServerConfig` не переносил `DedupKey`/`DedupPreferred`** в гидратированную копию | 🔴 **Фикс был бы мёртвым в продовом пути `Build()`.** `hostedToolsets` читает гидратированные конфиги → ключ всегда пуст → дедуп никогда не срабатывает. Мой юнит-тест звал `hostedToolsets` **напрямую**, минуя гидратацию, поэтому дефект не поймал | | `e888d5e` `fix(agentconfig): validate preferred MCP dedup` | `DedupPreferred: true` без `DedupKey` не отвергался | Бессмысленная конфигурация проходила молча | | `facb0d0` `fix(agentfactory): keep MCP dedup silent` | Убрал мой `fmt.Printf` из библиотеки; добавил ошибку при **нескольких** `DedupPreferred` в одной группе | Библиотека не должна писать в stdout; молчаливый выбор победителя делал активную авторизацию зависимой от порядка id | | — | **`strings.TrimSpace(key)` — нормализация ключа** | 🔴 Ключ обязан быть **opaque**: `"tenant-a"` и `" tenant-a "` — разные namespace'ы. Нормализация склеила бы независимо сконфигурированные namespace'ы и воссоздала бы cross-tenant merge, который этот API и должен предотвращать | **Заменённые строки в моём коде:** ```go // было (мой вариант) // стало (upstream/main) key := strings.TrimSpace(cfg.DedupKey) key := cfg.DedupKey if strings.TrimSpace(key) == "" { continue } skip[id] = true skip[id] = true // без fmt.Printf ``` + `hydrateMCPServerConfig` теперь копирует `DedupKey`/`DedupPreferred`. 📌 **ГЛАВНЫЙ УРОК (durable):** юнит-тест, вызывающий внутреннюю функцию **напрямую**, не покрывает продовый путь, где конфиг проходит через **гидратацию/нормализацию**. Мейнтейнер добавил `TestFactoryBuildKeepsPreferredDedupEndpoint` — он идёт через `f.Build()` (registry lookup → hydration → hostedToolsets). **Правило: тестировать сквозь композиционный путь, а не только целевую функцию.** Этот дефект дал бы «зелёные гейты и неработающий фикс» — ровно тот класс ошибки, против которого был весь PR. --- ## 0. ИТОГ — что сделано Коммит **`d4ab923`** `fix(agentfactory): dedup MCP toolsets by explicit key`. **Гейты локально — все зелёные:** ``` gofmt -l agentfactory/ agentconfig/ → чисто go build ./... → OK go vet ./... → OK go test ./... → все 10 пакетов ok go test -race ./... → все ok go tool golangci-lint run ./... → 0 issues ``` | # | Дефект ревью | Фикс | |---|---|---| | 1 | **P1** ключ стирал query и игнорировал `Headers` → слияние разных тенантов/авторизаций | `mcpEndpointKey` + `mcpURLIsScoped` **удалены**. Инференс из URL убран. Схлопывание только по явному непустому `DedupKey`; пусто → **никогда** не схлопывается | | 2 | **P2-1** `selectScopedOnly` копировал алгоритм прода | **удалён**. Тесты пишут endpoint каждого конфига, реально дошедшего до `mcpTransportFactory` (инъектируемая переменная), и ассертят на нём | | 3 | **P2-2** `ProcessRequest(ctx any, req any)` — вакуумный | Точная продакшн-сигнатура `(agent.Context, *model.LLMRequest)` + позитивный контроль `TestRequestProcessorInterfaceIsSatisfiable` | **Новые поля `agentconfig.MCPServerConfig`** (аддитивно, по умолчанию выключено): - `DedupKey` — явный ключ эквивалентности (opaque, задаёт вызывающий); - `DedupPreferred` — кто выживает в группе, когда записи не взаимозаменяемы. Приоритет победителя: помеченный `DedupPreferred` > лексикографически меньший id (детерминированно, не зависит от порядка map). **Balda — composition layer (как просил ревьюер):** - `sessionmemorymcp.BundledDedupKey = "balda-bundled-mcp"` — общий ключ; - `internal/apps/balda/internalmcp/manager.go:213` — bundled-запись получает `DedupKey`; - `internal/apps/balda/sessionapp/module.go:130` — scoped-binding получает `DedupKey` + `DedupPreferred: true`. **Тесты (8 в `agentfactory`):** `TestHostedToolsetsCollapsesScopedEndpointDuplicate`, `TestDistinctQueryParamsNeverCollapse`, `TestDistinctHeadersNeverCollapse`, `TestSameDedupKeyCollapsesThreeEntries`, `TestDedupPreferredDecidesPlaceholderWinner`, `TestDedupWithoutPreferenceIsLexicographicallySmallest`, `TestEmptyDedupKeyNeverCollapses`, `TestHostedToolsetsKeepsDistinctEndpoints`, `TestMcpToolsetRequestProcessorSignature`, `TestRequestProcessorInterfaceIsSatisfiable`. ### 🔴 Негативные контроли (доказано, что тесты не вакуумные) Проверено скриптом `/tmp/negctl.sh`: 1. Вернул инференс из URL (стрип query) → `TestDistinctQueryParamsNeverCollapse` **FAIL**: `expected 2 toolsets for distinct tenants, got 1`. Лог: `skipping duplicate toolset "tenant-b": dedup key "http://h:1/mcp" already served by "tenant-a"`. 2. Вернул `ctx any, req any` → **build failed**: `*stubRequestProcessor does not implement requestProcessor (wrong type for method ProcessRequest)`. 3. Файлы восстановлены **байт-в-байт** (`diff` чистый), ре-верификация зелёная. --- ## 1. 🔴 КЛЮЧЕВОЕ ОТКРЫТИЕ: scoped-запись ОБЯЗАНА выживать Изначально планировался лексикографический победитель (`balda` < `balda-session-memory-*`), т.е. **plain** запись. При проверке кода брокера выяснилось — это сломало бы session-memory: `internal/apps/balda/sessionmemorymcp/broker.go`: - `contextQueryParameter = "balda_context"` — токен читается **из query** (`:124`); - `Wrap` (`:118-150`): без токена запрос идёт дальше без инъекции `HeaderSessionLocator`/ `HeaderSessionID`/`HeaderSessionBinding`; - комментарий в коде: *«session-memory calls still fail closed in HeaderSessionResolver»*. → Токен в URL **функциональный**, а не косметический. Поэтому: 1. нужен был `DedupPreferred` (порядок id не подходит); 2. заметка в тесте исправлена: `?ctx=` → реальный `?balda_context=`. ⚠️ **Вывод на будущее:** старая эвристика `mcpURLIsScoped` («есть query → scoped») давала **правильного** победителя случайно. Замена на «лексикографически меньший» без `DedupPreferred` была бы регрессией. Проверка кода, а не симметрия с ревью, её поймала. --- ## 2. Что говорит ревью (дословно, 3 пункта) Ревьюер **metalagman**, `CHANGES_REQUESTED`, 2026-09-28T09:04:10Z (опубликовано в PR). | # | Severity | Суть | |---|---|---| | 1 | **P1 — blocking** | `mcpEndpointKey` выбрасывает все query-параметры, группировка игнорирует `Headers`. `/mcp?tenant=a` vs `?tenant=b`, или один URL с разными `Authorization`, схлопываются в один произвольный конфиг → доступ к чужому tool-set / пересечение границы доступа. Просьба: *«keep generic endpoint identity exact»* + *«resolve the Balda-specific duplicate in the Balda composition layer, or introduce explicit alias/replacement metadata»* | | 2 | **P2** | `selectScopedOnly` повторяет алгоритм продакшена и вычисляет победителя сам — не проверяет фактически удержанный toolset | | 3 | **P2** | `ProcessRequest(ctx any, req any)` — Go требует точных типов, реальный метод интерфейс не удовлетворит; ассерт не может сработать | **Validation ревьюера:** `go test -race ./...`, `go vet ./...`, `golangci-lint run ./...` — проходили. `no check runs/statuses` — тот же fork-блокер, что в PR #4. --- ## 3. ⛔ CI: внешний блокер (не наш дефект) `gh pr checks 5` → `no checks reported on the 'fix/hosted-toolsets-duplicate-endpoint' branch`. Причина та же, что в PR #4 (§0в в [[personal/plans/runtime-pr4-openai-toolcalls-fixes]]): **fork-PR** (`mallexxx/runtime` → `normahq/runtime`), workflows ждут одобрения мейнтейнера. Починить не можем. Локальные гейты эквивалентны CI (`test.yml` + `lint.yml`). --- ## 4. Питфоллы, найденные в работе 1. 🔴 **`write_file` с относительным путём пишет НЕ в vault.** Первый `write_file` с путём `personal/plans/runtime-pr5-...md` создал файл в `/Users/admin/Developer/balda/personal/...`. Для vault — **только `mcp_obsidian_write_note`** (vault root `/Users/admin/obsidian`). 2. ⚠️ **`rm -rf` в терминале требует подтверждения** — таймаут апрува заблокировал команду. Лишнее дерево `/Users/admin/Developer/balda/personal/` **не удалено** (см. §6). 3. ⚠️ **LSP-диагностика после `patch` бывает устаревшей** — показывала `undefined: mcpEndpointKey` после того, как `go build` уже был чист. Верить `go build`/`go test`, не LSP-снапшоту. 4. ⚠️ Правки — **только `patch`**, не sed/in-place (правило Alex). Ни одного sed не использовано. 5. ⚠️ Untracked `tools/` в репо — **не мой** файл, не коммитил. --- ## 5. Осталось сделать 1. ✅ **Ре-ревью пройдено, PR влит** (`6775d71`). 2. 🔴 **Синхронизация копии рантайма в Balda — ОТКРЫТО, блокирует боевой стенд.** `~/Developer/balda/runtime-local` — **не клон runtime**, а **снапшот кода эпохи PR #4** (3 файла отличаются от `upstream/main`, все три — в пользу апстрима; `DedupKey` — **0 вхождений**). Каталог **git-ignored** в balda и подключён через `replace github.com/normahq/runtime/v2 => ./runtime-local`. Правки Balda-слоя из §0 **не компилируются** против него (`unknown field DedupKey in struct literal of type agentconfig.MCPServerConfig`). ✅ **Потери при замене НЕТ** — проверено `diff -rq`: отличаются только `agentfactory/agentfactory.go`, `hostedagent/openai.go`, `hostedagent/openai_test.go` (все — устаревшие версии того, что уже в апстриме). ⚠️ В апстриме мой код **уже исправлен мейнтейнером** (§0а) — синхронизировать надо с `upstream/main` (`c41939f`), а не с моим `d4ab923`. План и развилка (убрать `replace` совсем vs заменить копию) — [[personal/plans/balda-runtime-rebuild-mm-validation]] §1/§3. 3. ⚠️ `/tmp/negctl.sh`, `/tmp/control1.py`, `/tmp/control2.py`, `/tmp/af.bak`, `/tmp/dt.bak`, `/tmp/commitmsg.txt`, `/tmp/reply.md`, `/tmp/pr5_af.go`, `/tmp/pr5_test.go` — временные. 4. ✅ Ветки PR #4/#5 больше не «утекли» — обе влиты. --- ## 6. ✅ РЕШЕНО 2026-09-28: синхронизация копии рантайма в Balda Открытый вопрос из §5 **закрыт в тот же день**. Изложен и решён в [[personal/plans/balda-runtime-rebuild-mm-validation]] §1. **Что было:** `~/Developer/balda/runtime-local` — снапшот файлов runtime без `.git`, снятый руками на эпоху PR #4, подключённый через `replace`. `DedupKey` (PR #5) в нём **отсутствовал**, поэтому Balda-слой дедупа не компилировался. **Что сделано:** после мержа PR #4/#5 в апстрим (тег **`v2.1.0`** = `c41939f` = `upstream/main`): - `go.mod` → `require github.com/normahq/runtime/v2 v2.1.0`, директива **`replace` УДАЛЕНА**; - `Dockerfile.release` → удалён `COPY runtime-local/ ./runtime-local/`; - каталог `runtime-local/` **вынесен из дерева** в `/tmp` (восстановимо). 📌 **Проверка перед удалением** (обязательна для любой такой копии): ```bash diff -rq runtime-local <чистый upstream-чекаут> ``` Отличались ровно **3 файла** (`agentfactory/agentfactory.go`, `hostedagent/openai.go`, `hostedagent/openai_test.go`) — и во всех трёх апстрим **новее**. Потери нет. **Результат:** образ **`balda-mm:0.1.14`** собран и задеплоен на Kraken. Приёмка пройдена: `duplicate tool:` = **0**, `function_response_part_count` = **1** (инструмент исполнен), ответ от аккаунта Alex, `settlement_stage=accepted`, `RestartCount=0`. ⚠️ **Побочный эффект:** пока `runtime-local/` лежал в корне, `go-arch-lint` давал 56 notices (осиротевший каталог чужого модуля). После выноса — `OK - No warnings found`. --- ## 6а. Требует решения Alex Удалить лишнее дерево, созданное ошибочным `write_file`: ``` /Users/admin/Developer/balda/personal/ ``` Содержит мой дубль `plans/runtime-pr5-…md` и **чужой** `tech/music-broken-toload.md` (не мой файл!). Удаление требует явного подтверждения — не трогал. 🔴 **Найденный питфолл (durable):** `write_file` с **относительным** путём пишет относительно cwd сессии, а НЕ в vault. Для vault — только `mcp_obsidian_write_note` (vault root `/Users/admin/obsidian`). --- ## 7. Контекст PR (исходная проблема) `hostedagent`/`agentfactory` — ADK разворачивает каждый toolset в один плоский список инструментов, а упаковка **не идемпотентна**: два сервера с одинаковым именем инструмента аварийно завершают ход с `duplicate tool: "balda.control.shutdown"`. Balda регистрирует bundled MCP-сервер дважды: как `"balda"` и как session-scoped binding того же listener'а с токеном в query. Оба отдают один список инструментов → каждый tool-using ход падал. Исходный фикс (`b01bdc6`) схлопывал по host/path — что и открыло P1. ## Связанные заметки - [[personal/plans/runtime-pr4-openai-toolcalls-fixes]] — PR #4, fork-CI-блокер, вопрос синхронизации копии - [[personal/tech/adk-hostedagent-openai-mcp]] — архитектура MCP/toolset, `runtime.mcp_servers` - [[personal/tech/balda-go-fx-pitfalls]] — метод дельт, питфоллы