11 KiB
Executable File
Создан 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:
- Вернул инференс из URL (стрип query) →
TestDistinctQueryParamsNeverCollapseFAIL:expected 2 toolsets for distinct tenants, got 1. Лог:skipping duplicate toolset "tenant-b": dedup key "http://h:1/mcp" already served by "tenant-a". - Вернул
ctx any, req any→ build failed:*stubRequestProcessor does not implement requestProcessor (wrong type for method ProcessRequest). - Файлы восстановлены байт-в-байт (
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 функциональный, а не косметический. Поэтому:
- нужен был
DedupPreferred(порядок id не подходит); - заметка в тесте исправлена:
?ctx=→ реальный?balda_context=<token>.
⚠️ Вывод на будущее: старая эвристика 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. Питфоллы, найденные в работе
- 🔴
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). - ⚠️
rm -rfв терминале требует подтверждения — таймаут апрува заблокировал команду. Лишнее дерево/Users/admin/Developer/balda/personal/не удалено (см. §6). - ⚠️ LSP-диагностика после
patchбывает устаревшей — показывалаundefined: mcpEndpointKeyпосле того, какgo buildуже был чист. Веритьgo build/go test, не LSP-снапшоту. - ⚠️ Правки — только
patch, не sed/in-place (правило Alex). Ни одного sed не использовано. - ⚠️ Untracked
tools/в репо — не мой файл, не коммитил.
5. Осталось сделать
- ⏳ Ре-ревью metalagman — ждать.
- ⛔ Синхронизация копии рантайма в Balda (
~/Developer/balda/runtime-local, HEAD05342c5a,grep -c DedupKey agentconfig/config.go→ 0). Правки Balda-слоя (§0) сейчас не компилируются против этой копии — ошибкиunknown field DedupKey in struct literal of type agentconfig.MCPServerConfig. Это отдельная задача, как и в PR #4 (решать после мержа, чтобы копия не разъехалась снова). 🔴 Пока не синхронизировано — боевой стенд Балды фикс не получил. - ⚠️
/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— временные. - ⚠️ Проверить, не утекла ли ветка 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 — метод дельт, питфоллы