Стабилизация, итерация 4: устойчивость тулов — валидация входа, MCP-кэш, ретраи по write-классу (один PR, 2 коммита) #489

Open
opened 2026-07-11 02:03:19 +03:00 by agent_vscode · 0 comments
Collaborator

Один PR. Зависит от #487 (коммит 1 — abortSignal у in-app тулов). Координаты — develop@363f20ab.

Коммит 1 — fix(ai-chat): валидация клиентских parts + insert после конвертации

ai-chat.service.ts:815-839: parts юзер-сообщения персистятся в metadata БЕЗ валидации (body читается без DTO) и реплеятся в convertToModelMessages на каждом ходе; insert user-строки идёт ДО конвертации.
Сценарий: битая структура parts (например, tool-part в input-available) → конвертация кидает → 500 на КАЖДОМ ходе навсегда («окирпиченный» чат); каждый ретрай добавляет дубль user-строки в уже битую историю.
Правка:

  1. Валидировать/санитизировать parts при приёме (whitelist типов: text + фактически используемые; прочее → отбрасывать с warn).
  2. Прогонять convertToModelMessages ДО insert'а user-строки (ретрай не плодит дубли).
  3. При падении конвертации на СТАРОЙ истории — per-row конвертация для изоляции отравленной строки; деградация битой строки до plain-text (textPart(content)) с маркером «[tool context omitted]» (молчаливая потеря tool-контекста недопустима — модель должна видеть усечение).
    Тесты: unit трёх веток; регресс «чат с битым сообщением в истории продолжает работать; маркер присутствует».

Коммит 2 — fix(ai-chat): MCP-кэш — per-server evict, in-run восстановление, ретраи только для readOnly

Сейчас: кэш внешних MCP-клиентов ключуется по workspaceId и держит клиентов ВСЕХ серверов в одной записи (mcp-clients.service.ts:121, TTL 60 c :72-73); health-check/reconnect-on-error нет — после разрыва SSE-транспорта (bodyTimeout 60 c режет при >60 c тишины МЕЖДУ вызовами, ai-streaming-fetch.ts:126) кэш отдаёт труп до конца TTL. Тулы рана захватывают клиент при toolsFor в начале хода — evict сам по себе лечит только СЛЕДУЮЩИЙ ход, а мотивационный сценарий («модель ретраит и жжёт шаги») происходит ВНУТРИ текущего рана.
Правка:

  1. Кэш → per-server под-записи (один флапающий сервер не обнуляет клиентов остальных).
  2. Обёртка execute при транспортной ошибке: пометить под-запись мёртвой + evict; CAS-своп байндинга по identity («подменяй, только если текущий байндинг == мой упавший клиент»; проигравший конкурентный вызов не свапает, ретраит на текущем).
  3. Лизы mid-run не освобождаются: ран копит set лиз (старая+новая) и освобождает ВСЕ на turn-end (release при свопе под конкурентным in-flight вызовом = индуцированный второй отказ).
  4. Авторетрай (ровно 1) — только для тулов, объявленных readOnly/idempotent: новое поле в SHARED_TOOL_SPECS (packages/mcp/src/tool-specs.ts) + registration-time assert «каждый спек объявляет write-класс». Транспортная ошибка НЕ означает «не применилось» (reset после обработки сервером) — слепой ретрай write-тула = double-apply (класс инцидента #435). Write-тул: без авторетрая, модели — indeterminate-ошибка «могло примениться, проверь перед повтором».
  5. Проверка abortSignal перед ретраем И перед чеканкой свежего клиента (не минтить соединение для абортированного рана); per-call cap явно покрывает «оба attempts + connect».
  6. Классификация «транспортной» ошибки — по РЕАЛЬНЫМ шейпам undici/@ai-sdk (isRetryableConnectError-подход), НЕ по мок-ошибкам (иначе evict в проде молча не сработает при зелёном CI).
  7. bodyTimeout для SSE-транспорта MCP — отдельно от HTTP-вызовов (тишина между вызовами легальна), поднять.
    Тесты: property на реальных error-шейпах «повторный вызов после обрыва ВНУТРИ того же рана получает живой клиент» (in-run путь, не только кросс-turn); «write-тул не авторетраится»; «ретрай после Stop не происходит».
Один PR. **Зависит от #487** (коммит 1 — abortSignal у in-app тулов). Координаты — develop@363f20ab. ### Коммит 1 — fix(ai-chat): валидация клиентских parts + insert после конвертации `ai-chat.service.ts:815-839`: `parts` юзер-сообщения персистятся в metadata БЕЗ валидации (body читается без DTO) и реплеятся в `convertToModelMessages` на каждом ходе; insert user-строки идёт ДО конвертации. Сценарий: битая структура parts (например, tool-part в `input-available`) → конвертация кидает → 500 на КАЖДОМ ходе навсегда («окирпиченный» чат); каждый ретрай добавляет дубль user-строки в уже битую историю. Правка: 1. Валидировать/санитизировать parts при приёме (whitelist типов: text + фактически используемые; прочее → отбрасывать с warn). 2. Прогонять `convertToModelMessages` ДО insert'а user-строки (ретрай не плодит дубли). 3. При падении конвертации на СТАРОЙ истории — **per-row конвертация** для изоляции отравленной строки; деградация битой строки до plain-text (`textPart(content)`) с маркером **«[tool context omitted]»** (молчаливая потеря tool-контекста недопустима — модель должна видеть усечение). Тесты: unit трёх веток; регресс «чат с битым сообщением в истории продолжает работать; маркер присутствует». ### Коммит 2 — fix(ai-chat): MCP-кэш — per-server evict, in-run восстановление, ретраи только для readOnly Сейчас: кэш внешних MCP-клиентов ключуется по workspaceId и держит клиентов ВСЕХ серверов в одной записи (`mcp-clients.service.ts:121`, TTL 60 c :72-73); health-check/reconnect-on-error нет — после разрыва SSE-транспорта (bodyTimeout 60 c режет при >60 c тишины МЕЖДУ вызовами, `ai-streaming-fetch.ts:126`) кэш отдаёт труп до конца TTL. Тулы рана захватывают клиент при `toolsFor` в начале хода — evict сам по себе лечит только СЛЕДУЮЩИЙ ход, а мотивационный сценарий («модель ретраит и жжёт шаги») происходит ВНУТРИ текущего рана. Правка: 1. Кэш → **per-server под-записи** (один флапающий сервер не обнуляет клиентов остальных). 2. Обёртка execute при транспортной ошибке: пометить под-запись мёртвой + evict; **CAS-своп байндинга по identity** («подменяй, только если текущий байндинг == мой упавший клиент»; проигравший конкурентный вызов не свапает, ретраит на текущем). 3. **Лизы mid-run не освобождаются:** ран копит set лиз (старая+новая) и освобождает ВСЕ на turn-end (release при свопе под конкурентным in-flight вызовом = индуцированный второй отказ). 4. **Авторетрай (ровно 1) — только для тулов, объявленных `readOnly`/`idempotent`:** новое поле в SHARED_TOOL_SPECS (`packages/mcp/src/tool-specs.ts`) + registration-time assert «каждый спек объявляет write-класс». Транспортная ошибка НЕ означает «не применилось» (reset после обработки сервером) — слепой ретрай write-тула = double-apply (класс инцидента #435). Write-тул: без авторетрая, модели — indeterminate-ошибка «могло примениться, проверь перед повтором». 5. Проверка abortSignal **перед ретраем И перед чеканкой свежего клиента** (не минтить соединение для абортированного рана); per-call cap явно покрывает «оба attempts + connect». 6. Классификация «транспортной» ошибки — по РЕАЛЬНЫМ шейпам undici/@ai-sdk (`isRetryableConnectError`-подход), НЕ по мок-ошибкам (иначе evict в проде молча не сработает при зелёном CI). 7. `bodyTimeout` для SSE-транспорта MCP — отдельно от HTTP-вызовов (тишина между вызовами легальна), поднять. Тесты: property на реальных error-шейпах «повторный вызов после обрыва ВНУТРИ того же рана получает живой клиент» (in-run путь, не только кросс-turn); «write-тул не авторетраится»; «ретрай после Stop не происходит».
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#489