> Создан 2026-09-28. Статус: **✅ РЕАЛИЗОВАНО, ЗАПУШЕНО. Ре-ревью ожидается.** > Голова PR: **`d4ab923`** (4 файла, +411/−1), ветка `fix/hosted-toolsets-duplicate-endpoint`. > **PR normahq/runtime#5** — OPEN. Ответ ревьюеру опубликован: > https://github.com/normahq/runtime/pull/5#issuecomment-5867224210 > Репо: `~/Developer/runtime-local`. Связано: [[personal/plans/runtime-pr4-openai-toolcalls-fixes]]. --- ## 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. ⏳ **Ре-ревью metalagman** — ждать. 2. ⛔ **Синхронизация копии рантайма в Balda** (`~/Developer/balda/runtime-local`, HEAD `05342c5a`, `grep -c DedupKey agentconfig/config.go` → **0**). Правки Balda-слоя (§0) **сейчас не компилируются** против этой копии — ошибки `unknown field DedupKey in struct literal of type agentconfig.MCPServerConfig`. Это **отдельная задача**, как и в PR #4 (решать после мержа, чтобы копия не разъехалась снова). 🔴 Пока не синхронизировано — боевой стенд Балды фикс **не получил**. 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 (`feat/openai-tool-calls-v2`) — она тоже открыта. --- ## 6. Требует решения Alex Удалить лишнее дерево, созданное ошибочным `write_file` (содержит только пустые `plans/` + `tech/`, ничего ценного): ``` /Users/admin/Developer/balda/personal/ ``` Удаление требует явного подтверждения — не трогал. --- ## 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]] — метод дельт, питфоллы