From addd5ba445fcdb6007a36e8c52dcaaa92ca7dd58 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Mon, 28 Sep 2026 15:34:42 +0600 Subject: [PATCH] [2026-09-28] eagle: personal/plans/runtime-pr5-toolset-dedup-review-fixes.md personal/tech/adk-hostedagent-openai-mcp.md --- .../runtime-pr5-toolset-dedup-review-fixes.md | 281 ++++++++---------- personal/tech/adk-hostedagent-openai-mcp.md | 48 ++- 2 files changed, 165 insertions(+), 164 deletions(-) diff --git a/personal/plans/runtime-pr5-toolset-dedup-review-fixes.md b/personal/plans/runtime-pr5-toolset-dedup-review-fixes.md index 19725912..d90e9890 100755 --- a/personal/plans/runtime-pr5-toolset-dedup-review-fixes.md +++ b/personal/plans/runtime-pr5-toolset-dedup-review-fixes.md @@ -1,183 +1,156 @@ -> Создан 2026-09-28. Статус: **📋 ПЛАН, ЖДЁТ ОДОБРЕНИЯ ALEX.** -> Ветка: `fix/hosted-toolsets-duplicate-endpoint` (@ `b01bdc6`, 2 файла, +263) -> **PR normahq/runtime#5** — OPEN, `CHANGES_REQUESTED` от **metalagman** (2026-09-28T09:04:10Z) +> Создан 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. Что говорит ревью (дословно, 3 пункта) +## 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`. И query, и headers могут выбирать тенанта / авторизационный контекст / режим сервера. `/mcp?tenant=a` и `/mcp?tenant=b` схлопываются в один произвольный конфиг → **доступ к чужому 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 — test gap** | `selectScopedOnly` **повторяет алгоритм продакшена** внутри теста и вычисляет ожидаемого победителя сам. Не проверяет, что реально удержанный toolset/транспорт — именно scoped URL. Ассерт вакуумный. | -| 3 | **P2 — test gap** | Локальный интерфейс `ProcessRequest(ctx any, req any) error` — Go требует **точных** типов параметров, поэтому реальный метод `ProcessRequest(agent.Context, *model.LLMRequest) error` **всё равно не удовлетворит** интерфейс. Тест не может поймать регрессию. | +| 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 ./...`, `go tool golangci-lint run ./...` — проходят. `GitHub currently reports no check runs/statuses` (тот же fork-блокер, что в PR #4). +**Validation ревьюера:** `go test -race ./...`, `go vet ./...`, `golangci-lint run ./...` — проходили. +`no check runs/statuses` — тот же fork-блокер, что в PR #4. --- -## 1. Подтверждение фактами +## 3. ⛔ CI: внешний блокер (не наш дефект) -### P1 — ключ стирает query и не смотрит Headers -`agentfactory/agentfactory.go` (`b01bdc6`), функция `mcpEndpointKey`: -```go -u.RawQuery = "" -u.Fragment = "" -return u.String() -``` -→ `http://h:1/mcp/balda?ctx=abc123` и `http://h:1/mcp/balda?tenant=b` дают **один ключ**. -`Headers` в ключе не участвуют вообще: `MCPServerConfig.Headers` есть -(`toRuntimeMCPServers` его клонирует, `httpClientWithHeaders` его применяет) — значит разные -`Authorization` на одном URL сейчас считаются «тем же сервером». **Это реальная дыра, ревью право.** - -Побочный эффект `mcpURLIsScoped`: «scoped» = «есть хоть какой-то query». Это тоже выводит -семантику из формы хоста/пути/строки — ровно то, против чего ревьюер. - -### P2-1 — тест дублирует прод -`duplicate_toolset_test.go:49,57-94`: `selectScopedOnly` — **копия** цикла `hostedToolsets` -(`keeperOf`/`skip`/`mcpURLIsScoped`). Тест проверяет копию алгоритма, а не результат работы -`hostedToolsets` (который вернул непрозрачные `tool.Toolset`; тест лишь считает `len(toolsets)`). -Замечание верное: если поменять приоритет в проде и в копии синхронно — тест останется зелёным. - -### P2-2 — вакуумный ассерт -```go -type requestProcessor interface { ProcessRequest(ctx any, req any) error } -``` -Продакшн-сигнатура (подтверждено в модульном кэше -`adk/v2@v2.4.0/internal/toolinternal/tool.go:41` и `tool/tool.go:192`): -```go -ProcessRequest(ctx agent.Context, req *model.LLMRequest) error -``` -`any` ≠ `agent.Context` → метод **не** реализует интерфейс ни в каком случае. `if _, ok := ...; ok` -всегда `false`, `Fatalf` недостижим. **Вакуумный.** +`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`). --- -## 2. План правок +## 4. Питфоллы, найденные в работе -### Фикс 1 (P1) — точная идентичность на generic-слое + явный алиас на слое Balda - -Разделяем **«когда схлопывать»** и **«какой именно дубль схлопывать»**. - -**(a) `agentfactory`: убрать умозаключения из хоста/пути.** -- Удалить `mcpEndpointKey` (стирание query) и `mcpURLIsScoped`. -- Ввести **явное** объявление эквивалентности, а не вывод из формы URL. Два кандидата: - - `MCPServerConfig.Aliases []string` — «id этого конфига дублирует id X» (аддитивно к - `agentconfig`; правка публичного API → документировать в `README.md` по правилу `AGENTS.md`); - - либо поле `DedupKey string` — «схлопывать только конфиги с одинаковым непустым ключом». -- **Рекомендация: `DedupKey string`** (`omitempty`). Почему: не требует знания id-ов, - не создаёт транзитивных связей, неинвазивно для остальных потребителей (пусто → не схлопывается - никогда). Generic-слой больше ничего не выводит — кто задал ключ, тот и отвечает за смысл. -- Схлопывание: только `key != "" && key == key` → **детерминированный** победитель (см. ниже). - -**(b) `balda` (composition layer) — объявить дубль явно.** -В `internal/apps/balda/agent/runtime_manager.go:344` (место `registry.Set(binding.ID, binding.Config)`) -проставлять `DedupKey` = endpoint **без** query, но **только** для связки -`bundledBaldaServerID` → session-scoped binding. Это то самое «resolve in the Balda composition -layer», чего просит ревьюер. Разные `?tenant=`/`Authorization` в общем слое остаются разными. - -**(c) Детерминированный победитель — без «scoped эвристики».** -Вместо `mcpURLIsScoped` (снова форма URL) — при `DedupKey`-группе размером >1 выбирать -**лексикографически меньший id** и логировать остальные как skipped. В Balda это даст -`balda` (стабильно, не зависит от случайного токена). 🔴 **Следствие для Balda:** session-токен -перестанет попадать в URL активного тулсета — надо подтвердить, что context-broker доставляет -контекст иначе (см. §3, риск 1). Если нет — победителя задавать явным полем -`DedupPreferred bool`, а не эвристикой. - -**Обоснование смены победителя (было: scoped, стало: явное/lexicographic):** -старый выбор «scoped» — это тоже неявный вывод, и ревьюер его отвергает по духу P1. -Явное поле/детерминированный порядок — предсказуемо и тестируемо. - -### Фикс 2 (P2-1) — тест на фактический результат - -Вместо копии алгоритма проверять **то, что реально вернул `hostedToolsets`**: -- поднять **два httptest-HTTP-сервера** (MCP handler) с *разными* tool-листами по разным путям; - настоящий `hostedToolsets` → вызвать `Tools()`/`Name()` полученных тулсетов и убедиться, что - виден tool-set сервера с ожидаемого URL; -- либо, если поднять MCP-сервер дорого, — прокинуть наружу фактически использованный endpoint через - **инъектируемый конструктор транспорта** (`var mcpTransportForConfig = ...` + подмена в тесте), - и ассертить `Transport.Endpoint`. Тогда тест падает, если прод удержал не тот конфиг. -- 🔴 Предпочтение: инъекция конструктора — дешевле, детерминированнее и не тянет сеть. Реализовать - как тестовый хук в `agentfactory` (тонкая обёртка), без изменения публичного API. - -### Фикс 3 (P2-2) — точная сигнатура - -Заменить вакуумный интерфейс: -```go -import ( - "google.golang.org/adk/v2/agent" - "google.golang.org/adk/v2/model" -) -type requestProcessor interface { - ProcessRequest(ctx agent.Context, req *model.LLMRequest) error -} -``` -Продакшн-типы (`agent.Context`, `*model.LLMRequest`) подтверждены в модульном кэше. -Плюс **позитивный контроль**: если ассерт вакуумный, тест должен падать. Добавить в тот же тест -проверку, что интерфейс **реализуется** заведомо подходящим типом (например, -`tool.SetRequestProcessor`-совместимой обёрткой или `*mcpTool` из `mcptoolset`) — если сигнатура -разъедется с продакшном, тест это покажет, а не будет молчать. - -### Тесты (контрактные) -| Тест | Что фиксирует | -|---|---| -| `TestDistinctQueryParamsNeverCollapseWithoutAlias` | `/mcp?tenant=a` vs `/mcp?tenant=b` (без `DedupKey`) → **2** тулсета | -| `TestDistinctHeadersNeverCollapseWithoutAlias` | один URL, разные `Authorization` → **2** тулсета | -| `TestSameDedupKeyCollapses` | два конфига с одинаковым `DedupKey` → **1**, и удержан **детерминированный** | -| `TestEmptyDedupKeyNeverCollapses` | `DedupKey:""` + одинаковые URL → **2** (generic-слой ничего не выводит) | -| `TestHostedToolsetsKeepsRetainedEndpoint` | через хук транспорта: удержан именно ожидаемый endpoint/id (замена `selectScopedOnly`) | -| `TestMcpToolsetRequestProcessorSignature` | точная сигнатура `agent.Context`/`*model.LLMRequest` + позитивный контроль | - -### Гейты -``` -gofmt -l agentfactory/ agentconfig/ -go build ./... -go test ./... && go test -race ./... -go vet ./... -go tool golangci-lint run ./... -``` +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/` в репо — **не мой** файл, не коммитил. --- -## 3. Риски / питфоллы +## 5. Осталось сделать -1. 🔴 **Смена победителя в Balda — поведенческий риск, не косметика.** Сейчас в URL живёт - `ctx=<токен>` (`binding.Config.URL`, ставит context-broker). Если удержать `balda` (без токена), - session-memory/context может перестать доставляться. **Перед реализацией (b/c) — проверить, - как композируется `binding.Config.URL` и нужен ли токен в URL вообще.** - Развилка: (i) токен не нужен → lexicographic-победитель; (ii) токен нужен → явное - `DedupPreferred` на scoped-конфиге. **Решение за Alex.** -2. ⚠️ Правка `agentconfig.MCPServerConfig` = **публичный API** модуля. По `AGENTS.md`: сохранять - публичные API, изменения документировать в `README.md`. `DedupKey string` — аддитивно, но - документировать обязательно. -3. ⚠️ Правки делать **только дельта-апплаером** (`tools/run-project-deltas.sh`, `check` → `apply`), - не sed/in-place (правило Alex). -4. 🔴 **Ветка PR #5 ≠ текущая локальная ветка.** Сейчас checked out `feat/openai-tool-calls-v2` - (PR #4). Нужен `git switch fix/hosted-toolsets-duplicate-endpoint` **до** правок; `git status` - чистый (untracked `tools/` — не коммитить). -5. ⚠️ **PR #4 и PR #5 оба открыты и оба форк-PR** → CI не запускается без одобрения мейнтейнера - (см. §0в в [[personal/plans/runtime-pr4-openai-toolcalls-fixes]]). Локальные гейты — эквивалент CI. -6. ⚠️ Ревьюер — **metalagman**. Ревью опубликовано в PR (в отличие от PR #4, где работали по тексту - Alex). Ответ ревьюеру писать в PR. +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`) — она тоже открыта. --- -## 4. Порядок (после одобрения) +## 6. Требует решения Alex -1. ⛔ **Ожидание решения Alex по §3.1** (нужен ли `ctx`-токен в URL удержанного тулсета) -2. ⛔ Ожидание решения по §2-Фикс1(a): `DedupKey` vs `Aliases` -3. `git switch fix/hosted-toolsets-duplicate-endpoint`; бэкап-коммит текущего состояния (проверить, что нет своих правок) -4. `agentconfig`: добавить поле (+ документировать в `README.md`) -5. `agentfactory`: убрать `mcpEndpointKey`/`mcpURLIsScoped`; схлопывание по явному ключу; детерминированный победитель; тестовый хук транспорта -6. `balda`: проставить `DedupKey` в composition layer (`runtime_manager.go:344`) -7. Переписать тесты: удалить `selectScopedOnly`, добавить 6 контрактных; фикс сигнатуры -8. Гейты (§2) — все зелёные -9. Коммит (Conventional Commits) → push → `gh pr checks 5` -10. Ответ ревьюеру в PR + обновить Obsidian (этот файл + [[personal/tech/adk-hostedagent-openai-mcp]]) +Удалить лишнее дерево, созданное ошибочным `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/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]] — метод дельт, питфоллы diff --git a/personal/tech/adk-hostedagent-openai-mcp.md b/personal/tech/adk-hostedagent-openai-mcp.md index 2e0c4e6d..be11c13a 100755 --- a/personal/tech/adk-hostedagent-openai-mcp.md +++ b/personal/tech/adk-hostedagent-openai-mcp.md @@ -5,7 +5,9 @@ > Проект: [[personal/plans/balda-mm-phase4-agents]] §19, [[personal/projects/balda/balda-kraken-mm-deploy]] §19. > > 🔴 **ОБНОВЛЕНО 2026-09-28:** первая версия фикса дедупа (§4) и два теста (§5, §6) -> **отклонены ревью PR #5**. Правки помечены в §4a / §5 / §6 — читать их вместе с §4. +> **отклонены ревью PR #5**. §4a объясняет причину и **описывает замену, реализованную в `d4ab923`** +> (явный `DedupKey`/`DedupPreferred` вместо вывода из URL). §5 и §6 помечены `✅ ИСПРАВЛЕНО`. +> Читать §4 только как историю — не копировать. --- @@ -137,16 +139,29 @@ extraMCPServerIDs = append(extraMCPServerIDs, binding.ID) | P2 | test gap | `selectScopedOnly` (§5) **дублирует алгоритм продакшена** и сам вычисляет «ожидаемого» победителя → не проверяет фактически удержанный конфиг. Вакуумный ассерт. | | P2 | test gap | Пробник `ProcessRequest(ctx any, req any)` (§6) — Go требует **точных** типов, реальная подпись `(agent.Context, *model.LLMRequest)` его **не** удовлетворяет → проверка не может сработать. | -📌 **Направление замены (ожидает одобрения Alex):** -- generic-слой (`agentfactory`) **перестаёт выводить** идентичность из формы URL; -- схлопывание только по **явному** ключу — предлагается `DedupKey string` в - `agentconfig.MCPServerConfig` (альтернатива — `Aliases []string`); -- Balda объявляет свой дубль **сама** в composition layer — `internal/apps/balda/agent/runtime_manager.go:344` - (`registry.Set(binding.ID, binding.Config)`) — ровно этого просит ревьюer («resolve the Balda-specific - duplicate in the Balda composition layer»); -- победитель — **детерминированный** (lexicographic) или явное поле, но **не** эвристика по наличию query. +📌 **Направление замены — РЕАЛИЗОВАНО 2026-09-28 в `d4ab923`** (PR #5): +- generic-слой (`agentfactory`) **перестал выводить** идентичность из формы URL — + `mcpEndpointKey` и `mcpURLIsScoped` удалены; +- схлопывание только по **явному** ключу: `DedupKey string` в `agentconfig.MCPServerConfig` + (+ `DedupPreferred bool` — кто выживает, когда записи не взаимозаменяемы); +- Balda объявляет свой дубль **сама** в composition layer: + `internal/apps/balda/internalmcp/manager.go:213` (bundled) и + `internal/apps/balda/sessionapp/module.go:130` (scoped-binding) ставят общий + `sessionmemorymcp.BundledDedupKey = "balda-bundled-mcp"`; +- победитель — **детерминированный**: помеченный `DedupPreferred` > лексикографически меньший id. -⚠️ Разные `?tenant=`/`Authorization` в общем слое обязаны оставаться **разными серверами**. +📌 **Пустой `DedupKey` = дедуп выключен.** Это safety-свойство: конфиги, которые раньше +сливались по общему host/path, теперь **по умолчанию остаются разными**. Есть тест +`TestEmptyDedupKeyNeverCollapses`. + +⚠️ Разные `?tenant=`/`Authorization` в общем слое остаются **разными серверами** — тесты +`TestDistinctQueryParamsNeverCollapse` и `TestDistinctHeadersNeverCollapse`. + +⚠️ **Почему понадобился `DedupPreferred`, а не порядок id.** Лексикографически меньший id — это +`balda` (plain, **без** токена). Простая замена эвристики на сортировку была бы регрессией: +токен в URL функциональный (`contextQueryParameter = "balda_context"`, `Wrap` инжектит +`HeaderSessionLocator`/`HeaderSessionID`/`HeaderSessionBinding`, без токена session-memory +fail closed). Проверка кода брокера, а не симметрия с ревью, это поймала. --- @@ -178,6 +193,19 @@ extraMCPServerIDs = append(extraMCPServerIDs, binding.ID) либо подняв реальные httptest-MCP-серверы с разными tool-листами. Урок усиливается: **копия логики в тесте — тот же класс ошибки, что терпимый ассерт.** Тест обязан наблюдать **результат продакшена**. +✅ **ИСПРАВЛЕНО в `d4ab923`.** `selectScopedOnly` удалён. Реализован **тестовый хук**: +`var mcpTransportFactory = mcpTransportForConfig` — тест подменяет её на записывающую и ассертит +`Transport.Endpoint` конфига, реально дошедшего до сборки транспорта. Плюс два **негативных +контроля** (проверено скриптом): +1. возврат инференса из URL (стрип query) → `TestDistinctQueryParamsNeverCollapse` **FAIL**: + `expected 2 toolsets for distinct tenants, got 1`; +2. возврат `ctx any, req any` → **build failed** + (`*stubRequestProcessor does not implement requestProcessor`). + +📌 **Правило, выведенное на практике:** новый тест на баг обязательно проверять **негативным +контролем** — вернуть баг и убедиться, что тест падает. Тест, который не падает на сломанном +коде, — это не тест, а его имитация. См. навык `batch-data-edit-review-loop`. + --- ## 6. Метод: исполняемый пробник вместо чтения