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

197 lines
15 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. Статус: **✅ ВЛИТО В АПСТРИМ (2026-09-28T13:22:13Z).**
> Мой коммит: **`d4ab923`** (4 файла, +411/−1), ветка `fix/hosted-toolsets-duplicate-endpoint`.
> **PR normahq/runtime#5** — **MERGED** (merge-коммит `6775d71`).
> Ответ ревьюеру: https://github.com/normahq/runtime/pull/5#issuecomment-5867224210
> Репо: `~/Developer/runtime-local`. Связано: [[personal/plans/runtime-pr4-openai-toolcalls-fixes]].
---
## 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. Требует решения 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]] — метод дельт, питфоллы