Files
obsidian-vault/personal/plans/runtime-pr5-toolset-dedup-review-fixes.md
T

285 lines
23 KiB
Markdown
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
> Создан 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=<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. Питфоллы, найденные в работе
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]] — метод дельт, питфоллы