23 KiB
Executable File
Создан 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, на котором держится запуск Балды (в YAMLbase_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):
- 🔴 Добавляя поле в конфиг — грепнуть ВСЕ места ручного копирования структуры.
hydrateMCPServerConfigсобираетMCPServerConfigполе за полем; компилятор такое не ловит (пропущенное поле = zero value, валидный код). Искатьgrep -n "MCPServerConfig{"во всём репо. Это ровно тот класс дефекта, что и «терпимый ассерт» / «копия логики в тесте»: выглядит рабочим, молчит в проде. - ⚠️ Opaque = opaque. Назвал ключ «непрозрачным» у вызывающего — не нормализуй его сам.
TrimSpaceвыглядел гигиеной, а был дырой безопасности. - ⚠️ Библиотека не печатает в stdout. Логирование — дело потребителя.
- 📌 Тест сквозного пути ловит то, что юнит-тест уровня функции пропускает.
Мой тест дёргал
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 и должен предотвращать |
Заменённые строки в моём коде:
// было (мой вариант) // стало (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:
- Вернул инференс из 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. Осталось сделать
- ✅ Ре-ревью пройдено, PR влит (
6775d71). - 🔴 Синхронизация копии рантайма в 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. - ⚠️
/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/#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(восстановимо).
📌 Проверка перед удалением (обязательна для любой такой копии):
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 — метод дельт, питфоллы