fix(ai-chat,page): дружелюбные ошибки tool-input (#190) + защита от цикла при встречных перемещениях (#159 #9) #200
Closed
vvzvlad
wants to merge 3 commits from
fix/issues-190-159 into develop
pull from: fix/issues-190-159
merge into: vvzvlad:develop
vvzvlad:main
vvzvlad:test/244-part-b
vvzvlad:fix/255-ws-redis-adapter-leak
vvzvlad:feat/251-intentional-clear
vvzvlad:fix/252-e2e-open-handles
vvzvlad:feat/184-autonomous-agent-runs
vvzvlad:feat/221-image-captions
vvzvlad:feat/git-sync
vvzvlad:refactor/193-tool-spec-registry
vvzvlad:fix/244-dataloss-bugs
vvzvlad:fix/embeddings-reindex-progress
vvzvlad:develop
vvzvlad:feature/offline-sync
vvzvlad:feat/229-catalog-yaml
vvzvlad:feat/243-blob-sandbox
vvzvlad:feat/228-inline-footnotes
vvzvlad:fix/qa-ui-bugs-216-218
vvzvlad:feature/agent-roles-catalog
vvzvlad:fix/share-alias-rename
vvzvlad:fix/ai-chat-empty-render
vvzvlad:feat/191-chat-doc-binding
vvzvlad:feat/201-temporary-notes
vvzvlad:feat/198-interrupt-agent
vvzvlad:feat/ai-chat-full-history
vvzvlad:feat/199-ai-generate-title
vvzvlad:feat/205-share-aliases
vvzvlad:batch/issues-189-187-170
vvzvlad:feat/170-mcp-test-button
vvzvlad:feat/189-context-badge
vvzvlad:feat/198-interrupt-agent-send-now
vvzvlad:fix/ai-chat-new-chat-during-stream
vvzvlad:fix/ai-chat-stream-perf
vvzvlad:batch/issues-2026-06-25
vvzvlad:feat/ai-chat-persistent-history
vvzvlad:fix/ai-chat-copy-chat-wysiwyg
vvzvlad:fix/ai-stream-reset-resilience
vvzvlad:fix/ai-stream-undici-timeout
vvzvlad:fix/footnote-review-1227-followup
vvzvlad:fix/ai-chat-token-counter-realtime
vvzvlad:docs/manual-qa-test-plan
No Reviewers
Labels
Clear labels
bug
documentation
duplicate
enhancement
epic
feature
good first issue
help wanted
idea
invalid
needs-human
question
refactor
review/approved
review/changes-requested
review/needs
security
status/blocked
status/done
status/in-progress
status/ready
test
wontfix
Something isn't working
Improvements or additions to documentation
This issue or pull request already exists
New feature or request
Large multi-phase effort spanning many changes
New functionality request
Good for newcomers
Extra attention is needed
Idea / proposal for discussion
This doesn't seem right
эскалация: нужно решение человека
Further information is requested
Code cleanup / refactoring
в последнем ревью нет открытых blocking-находок
последнее ревью оставило открытые blocking-находки
head не ревьюился (head != reviewed_head)
Security / hardening issue
ждёт зависимость blocked_by
закрыто и проверено
в активной работе (мягкая заявка)
специфицировано, не заблокировано, ждёт исполнителя
Test coverage / test infrastructure
This will not be worked on
No Label
bug
Milestone
No items
No Milestone
Projects
Clear projects
No project
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: vvzvlad/gitmost#200
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Delete Branch "fix/issues-190-159"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Батч из двух независимых правок на одной ветке (
fix/issues-190-159), по одному коммиту на находку.fix(ai-chat): дружелюбные ошибки валидации tool-input — #190
При параллельной партии tool-call'ов модель иногда роняет обязательный параметр (обычно повторяющийся
pageId). zod отклонял ввод, а AI SDK отдавал модели сырой zod-текст (Invalid input: expected string, received undefined) — по нему модель не могла осознанно повторить вызов.Добавлен централизованный враппер входных схем
modelFriendlyInput(shape):z.toJSONSchema(z.object(shape), { target: 'draft-7' })—required/description/ограничения не меняются (контракт сохранён);validateвозвращает человекочитаемое сообщение с именем проблемного параметра и подсказкой повторить со всеми обязательными полями;deletePageне пробрасываетpermanentlyDelete/forceDelete;transformPage—deleteComments).Применён в
ai-chat-tools.service.ts(билдерsharedTool+ все инлайн-тулы) иpublic-share-chat-tools.service.ts. Значения не угадываются, контракт required/optional не тронут.fix(page): защита от цикла при встречных перемещениях — #159 (находка #9)
Гонка двух встречных перемещений (A тянет X под Y, B одновременно Y под X) проходила проверку цикла против устаревшего снимка, и обе записи коммитились →
X.parent=YИY.parent=X: цикл без пути к корню, рекурсивные ancestor-CTE ломаются, оба поддерева пропадают из сайдбара. Guard и запись не были в транзакции.Для подлинного переноса под конкретного родителя guard+запись обёрнуты в одну
executeTx-транзакцию: строки перемещаемой страницы и целевого родителя блокируютсяFOR UPDATEв каноническом (по id) порядке (сериализация без дедлока), проверка цикла перевыполняется внутри транзакции (проигравший видит закоммиченный репарент и отклоняется существующей ошибкой «Cannot move a page into its own subtree»). Реордер в том же родителе и перенос в корень идут прежним путём (цикл невозможен). Phantom-broadcast gate иPAGE_MOVEDбез изменений.Границы: закрыта двухсторонняя гонка (находка #9). Трёхсторонняя цепочка (A→B, B→C, C→A) — вне рамок, отмечена как известное ограничение. Находка #5 (токенный бюджет) намеренно не делается (депириоритезирована в самом issue).
Тесты
model-friendly-input.spec.ts(сообщение именует параметр + подсказка; лишние ключи срезаются; JSON-схема сохраняет required/description); два.parse()-теста гайдрейлов переведены на новыйvalidate-путь.findByIdс{ withLock: true, trx }для обоих id в отсортированном порядке; trx прокинут вgetPageBreadCrumbs/updatePage) + сохранены прежние гарантии; интеграционныйpage-move-cycle-concurrency.int-spec.ts(реальный Postgres в CI): два встречных перемещения дают ровно один успех + один отказ и никогда не сохраняют цикл.tsc --noEmitчистый; затронутые юнит-наборы зелёные.fix/ai-chat-new-chat-during-stream).When the in-app AI chat issued a parallel batch of tool calls, the model sometimes dropped a required parameter (typically a repeated `pageId`). zod rejected it and the AI SDK surfaced the raw zod text ("Invalid input: expected string, received undefined") to the model, which is not actionable — so it could not reliably retry. Add a centralized `modelFriendlyInput(shape)` wrapper for in-app tool input schemas: - the model-facing JSON schema is derived from the SAME zod shape via `z.toJSONSchema(z.object(shape), { target: 'draft-7' })`, so `required`, `description` and field constraints are unchanged (contract preserved); - on a validation failure `validate` returns a human-readable Error naming the offending parameter(s) plus a fixed retry hint ("Include every REQUIRED parameter ... do not drop ids like pageId"), which the SDK relays to the model via InvalidToolInputError; - on success it returns the parsed (unknown-key-stripped) data, preserving the existing strip guardrails (deletePage never forwards permanentlyDelete/ forceDelete; transformPage never forwards deleteComments). Applied in ai-chat-tools.service.ts (the sharedTool builder + every inline tool inputSchema) and in public-share-chat-tools.service.ts. Values are never guessed and the required/optional contract is untouched. Tests: new model-friendly-input.spec.ts (friendly message names the param + hint; unknown keys stripped; JSON schema keeps required/description); the two .parse()-based guardrail tests reworked to assert via the new validate path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>Code review — PR #200: дружелюбные ошибки tool-input в AI-чате (#190) + guard от циклов при конкурентных перемещениях страниц (#159)
Вердикт: Approve with comments. Логика корректна и безопасна (security/regressions чисты, цикл при гонке re-parent действительно сериализуется через
FOR UPDATE+ re-check внутри транзакции). Блокирует только одно: по конвенции репозитория обе пользовательские правки должны попасть вCHANGELOG.md [Unreleased], а он не тронут. Остальное — non-blocking.Объём: дифф
develop…fix/issues-190-159(merge-base3ddc329b), 8 файлов, +467/−79. Прогнаны параллельные аспектные ревьюеры (security, stability, conventions, documentation, regressions, test-coverage, simplification, architecture) + judge-проход.Must fix before merge
CHANGELOG.md [Unreleased]для #190 и #159 —CHANGELOG.md:11Репозиторий ведёт Keep-a-Changelog с секцией
[Unreleased], и недавние PR (#163, #146/#147, #183) последовательно добавляли пункты по каждой пользовательской правке; ревью-коммиты прошлых PR трактовали отсутствие записи как дефект. PR везёт два user-facing изменения (исправление потери поддеревьев из-за цикла при конкурентном перемещении — #159; дружелюбные ошибки валидации tool-input в AI-чате с указанием параметра и retry-подсказкой вместо сырого zod-текста — #190), ноCHANGELOG.mdв диффе пустой. Fix: под## [Unreleased]добавить### Fixed-пункт для #159 (опаздывающие re-parent сериализуютсяFOR UPDATEс re-check цикла внутри транзакции) и### Added/### Changed-пункт для #190, в стиле существующих записей с ссылками на (#159) и (#190).Non-blocking
[stability] Брать lock по каноническим UUID (
movedPage.id,parentPageId), а не по сыромуdto.pageId—apps/server/src/core/page/services/page.service.ts:943-944Защита от дедлока держится на том, что оба встречных перемещения сортируют ОДНИ И ТЕ ЖЕ две строки-идентификатора и берут
FOR UPDATEв одинаковом порядке. Сейчасconst lockIds = [dto.pageId, parentPageId].sort():parentPageId— уже разрешённый UUID (parentPage.id), аdto.pageId— сырое клиентское значение, иfindByIdпринимает также slugId (MovePageDto.pageId— неограниченный@IsString). Sidebar всегда шлёт UUID, так что на практике безопасно, но REST/agent-клиент, передавший slugId в двух одновременных встречных перемещениях (A: X под Y, B: Y под X), даст противоположный физический порядок блокировок и воспроизведёт AB-BA дедлок, который Postgres разрешит, отдав 500 вместо дружелюбногоBadRequestException. Повреждения данных нет (цикл всё равно предотвращается), но контракт «не может задедлочиться» из комментария нарушается для slug-входа. Fix:const lockIds = [movedPage.id, parentPageId].sort();—movedPage.idэто UUID, разрешённый контроллером,parentPageIdуже UUID;dto.pageIdдля breadcrumb-check /updatePageоставить как есть (та же строка в транзакции).[simplification] Вынести дублированный stub
makeChain(Proxy для chainable-trx) в один модульный хелпер —apps/server/src/core/page/services/page.service.spec.ts:46-56,327-337PR добавляет две байт-в-байт копии Proxy-стаба
makeChainповерх уже существующей в блокеmovePageToSpace()на develop — три идентичные копии ~10-строчного хелпера в одном файле, каждую правку trx-стаба придётся делать в трёх местах (сами комментарии диффа отмечают, что «mirror the pattern»). Fix: завести одинmakeChain()/makeTrxStub()на уровне describePageService(или модуля) и переиспользовать во всех трёх местах, удалив новые дубли.Test coverage
Новая логика покрыта неравномерно: есть unit-спеки cycle-guard и интеграционный тест конкурентных перемещений, а
model-friendly-input.spec.tsпокрывает основной путь форматтера. Без покрытия остаются:[test-coverage] Покрыть unlocked else-ветку
movePage(move-to-root / reorder под тем же родителем) —apps/server/src/core/page/services/page.service.ts:939-977Дифф разветвляет
movePageна locked-путь (typeof parentPageId === 'string') и новую else-ветку, выполняющуюthis.pageRepo.updatePage(updateValues, dto.pageId)БЕЗ транзакции для move-to-root и same-parent reorder. Все movePage-спеки и интеграционный тест используют толькоparentPageId:'dest-parent'(locked-ветку); self-move отвергается до развилки. То есть самая частая бытовая операция — перемещение в корень/реордер — не прогоняется ни одним тестом; регрессия в условии развилки или в аргументах unlocked-update пройдёт весь suite. Fix: в describe «movePage cycle guard» добавить тест сdto.parentPageId === null(move-to-root), проверяющий, чтоupdatePageвызван БЕЗ trx (mock.calls[0][2]undefined) иthis.db.transactionне вызывался; опционально кейс same-parent reorder с теми же проверками.[test-coverage] Покрыть ветку де-дупликации повторяющегося параметра в
formatIssues—apps/server/src/core/ai-chat/tools/model-friendly-input.ts:456-468formatIssuesреализует де-дуп повторяющихся имён параметров (if (seen.has(name)) continue;), но ни один тест не порождает два zod-issue на один path, так что ветка не исполняется. Косметика, низкий риск, но это заявленное новое поведение, ничем не зафиксированное. Fix: вmodel-friendly-input.spec.tsдобавить форму, дающую два issue на один параметр (например refine + type error), и проверить, что имя параметра в сообщении не повторяется.Architecture & design (forward-looking, non-blocking)
apps/server/src/core/ai-chat/tools/model-friendly-input.tsvspackages/mcp/src/tool-specs.ts(SharedToolSpec.buildShape),packages/mcp/src/index.tsНовый
modelFriendlyInput()чисто консолидирует in-app путь (заменяет ~25 inlinez.object(...)одним хелпером, который выводит JSON-схему draft-07 И прикрепляет дружелюбную ошибку:formatIssues+RETRY_HINT). Но источник схемы общий: и in-app сервисы, и standalone MCP-сервер строят input-схемы из однойSharedToolSpec.buildShape. PR добавляет дружелюбное поведение только на in-app ветке; MCP-сервер по-прежнему регистрирует сырой zod-shape, так что внешние MCP-клиенты на том же сценарии «потерял pageId в параллельном батче» (ради которого затевался #190) получат сырой zod-текст. Не дефект данного изменения — #190 был ограничен in-app чатом и там решён полностью; важно лишь если тот же сбой заденет внешнего MCP-клиента.formatIssues/RETRY_HINTили потребует рефактора.packages/mcp) с opt-in для обоих потребителей (effort: M). Pros:formatIssues+RETRY_HINTстановятся единым источником формулировок для in-app и standalone MCP; хелпер живёт там же, где схема; новые tool-ы наследуют единый UX. Cons: общая spec намеренно zod-/SDK-агностична (in-app — zod v4, MCP — v3; in-app-обёртка зависит отaijsonSchema/Schema иz.toJSONSchemav4), вниз можно опустить только чистую частьformatIssues(ZodError)->string; аккуратное расщепление муторно и может быть избыточной абстракцией под единственный пока потребитель.Рекомендация ревьюера-архитектора: склонность к варианту A (поверхность #190 — in-app чат, она закрыта; чистый hoist нетривиален при отсутствии конкретного второго потребителя), плюс однострочная заметка у
SharedToolSpec.buildShape, что friendly-error контракт пока живёт только в in-app обёртке.Pull request closed