fix(ai-chat): «send now» во время detached-run — серверный stop + ограниченный ретрай 409 (#396) #456
Open
agent_coder
wants to merge 1 commits from
fix/396-sendnow-detached-run into develop
pull from: fix/396-sendnow-detached-run
merge into: vvzvlad:develop
vvzvlad:main
vvzvlad:feat/424-drawio-rules
vvzvlad:fix/409-invalid-node-validation
vvzvlad:refactor/448-generate-inventory
vvzvlad:refactor/445-execute-mapping
vvzvlad:refactor/446-derive-client-types
vvzvlad:fix/447-registry-stamp-ci
vvzvlad:fix/444-agent-loop-guards
vvzvlad:fix/452-immutable-cache-control
vvzvlad:develop
vvzvlad:docs/footnote-authoring-comment-cleanup
vvzvlad:feat/437-error-diagnostics
vvzvlad:fix/442-cursor-pagination
vvzvlad:feat/419-footnote-normalize
vvzvlad:perf/399-comment-resolve-async
vvzvlad:perf/435-collab-token-cache
vvzvlad:perf/344-background-rerenders
vvzvlad:perf/348-backend-lowhanging
vvzvlad:feat/423-drawio-crud
vvzvlad:perf/401-collab-hotpath
vvzvlad:feat/430-live-reconnect
vvzvlad:perf/400-collab-session
vvzvlad:refactor/414-dedup-node-ops
vvzvlad:feat/417-new-comments-signal
vvzvlad:feat/408-createcomment-hints
vvzvlad:feat/407-persist-tool-errors
vvzvlad:feat/420-footnote-render
vvzvlad:dummy-review/mcp-hang-fix
vvzvlad:dummy-review/mcp-hang-base
vvzvlad:feat/381-resumable-sse-pr1
vvzvlad:feat/git-sync-2
vvzvlad:docs/agents-workspace-build-order
vvzvlad:feature/offline-sync
vvzvlad:perf/342-code-splitting
vvzvlad:perf/346-compression-cache
vvzvlad:feat/196-multi-cursor
vvzvlad:feat/370-page-versioning
vvzvlad:perf/343-typing-latency
vvzvlad:docs/how-to-test
vvzvlad:fix/ai-sdk-partial-output-oom
vvzvlad:test/351-generative-converter
vvzvlad:feat/371-roles-catalog
vvzvlad:refactor/345-server-converter
vvzvlad:refactor/294-spec-registry-cont
vvzvlad:fix/363-migration-order
vvzvlad:fix/e2e-callout-and-gate-build
vvzvlad:fix/docker-re2-toolchain
vvzvlad:feat/git-sync
vvzvlad:fix/media-roundtrip-stability
vvzvlad:fix/340-comment-panel-perf
vvzvlad:fix/332-deferred-tools
vvzvlad:fix/329-ephemeral-suggestions
vvzvlad:fix/330-search-in-page
vvzvlad:fix/328-resolved-anchor-spam
vvzvlad:fix/331-intraline-diff
vvzvlad:fix/324-coverage-gate
vvzvlad:fix/325-mobile-390
vvzvlad:feat/293-A-git-sync-package
vvzvlad:feat/300-avatar-oklch
vvzvlad:fix/321-banner-mobile
vvzvlad:feat/300-avatar-colors
vvzvlad:feat/315-comment-suggestions
vvzvlad:feat/scroll-restore-stable-wait
vvzvlad:feat/300-agent-avatar-stack
vvzvlad:feat/300-avatar-polish
vvzvlad:refactor/294-tool-spec-registry
vvzvlad:feat/scroll-restore-ux
vvzvlad:fix/responsive-tablet-sidebar
vvzvlad:feature/ai-chat-page-change-observability
vvzvlad:image-inline-center
vvzvlad:fix/283-short-remap-title
vvzvlad:fix/283-slash-layout
vvzvlad:image-inline-row
vvzvlad:feat/276-ai-chat-dock
vvzvlad:fix/269-table-menu-refocus
vvzvlad:docs/dev-stand-guide
vvzvlad:feat/266-scroll-position
vvzvlad:fix/260-collab-docname-slugid
vvzvlad:test/244-phase2-tail
vvzvlad:fix/262-reindex-progress-realtime
vvzvlad:fix/258-changelog-compare-links
vvzvlad:fix/244-dataloss-bugs
vvzvlad:feat/246-spoiler
vvzvlad:feat/221-image-captions
vvzvlad:test/244-part-b
vvzvlad:feat/251-intentional-clear
vvzvlad:fix/embeddings-reindex-progress
vvzvlad:refactor/193-tool-spec-registry
vvzvlad:fix/255-ws-redis-adapter-leak
vvzvlad:fix/252-e2e-open-handles
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/issues-190-159
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
epic
needs-human
review/approved
review/changes-requested
review/needs
возможно, баг
Large multi-phase effort spanning many changes
эскалация: нужно решение человека
в последнем ревью нет открытых blocking-находок
последнее ревью оставило открытые blocking-находки
head не ревьюился (head != reviewed_head)
Похоже на баг, но требует подтверждения/воспроизведения; чиним, если повторится
No Label
review/approved
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#456
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/396-sendnow-detached-run"
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?
Summary
В автономном режиме «Interrupt and send now» во время живого detached-run показывал красный баннер
409 A_RUN_ALREADY_ACTIVE, новый turn не стартовал.sendNowделал только локальныйstop()(abort SSE), который сервер по дизайну #184/#234 игнорирует (detached-run живёт), а последующийonFinish→flushPOST упирался в гейт «один активный run на чат».handleStopделает правильно (доп.onServerStop),sendNow— нет.closes #396
Вариант A (клиентский, горячий путь сервера не тронут):
sendNowв автономном режиме дополнительно зовётonServerStop(chatId)(или откладывает черезstopPendingRef, если chatId ещё не усыновлён — какhandleStop) и взводит one-shotsupersedeRetryRefДОstop();fetchна supersede-отправке (и только на ней) ретраит РОВНО409сbody.code===A_RUN_ALREADY_ACTIVEдо 4 попыток с бэкоффом 150/300/600ms;onServerStopгарантирует осадку run → ретрай сходится. Обычная отправка падает на 409 мгновенно (регресс-гард two-tab).isRunAlreadyActiveчитаетresponse.clone()→ оригинальное тело возвращается потребителю ai-sdk нетронутым.insert) → повтор POST безопасен, дублей нет.How verified
Стенд (vitest):
tsc --noEmit -p apps/client→ без новых ошибок (единственная — предсуществующаяimage-menu.tsx).vitest run chat-thread.test.tsx→ 42 passed: autonomous sendNow зовёт onServerStop; legacy — нет; supersede ретраит 409 и проходит на 2-й; one-shot; bounded(4); игнор non-409 и 409-с-другим-body; legacy-no-op; strand-path (взведён, но POST не ушёл → следующая обычная отправка НЕ ретраит).Внутренний цикл: 2 прохода. Ревью нашло CRITICAL:
supersedeRetryRefзастревал взведённым, когдаsendNowвзвёл, но POST не ушёл (promoted head удалён →flushNext()false; либо wasResumed) — следующая обычная отправка молча ретраила настоящий 409. Починка: разоружать флаг симметрично остальным one-shot (в ветке!flushNext()и в isStreaming-defuse-эффекте; транспорт read-and-clear на входе). Мутационно: убрать disarm → strand-тест краснеет (4 вызова вместо 1). Комментарий про «can never leak» приведён к реальному инварианту.Форк ревьюеру (не блокер, сознательно не делал): backoff-
setTimeoutне реагирует на abort/unmount во время сна (до 600ms лишней задержки, без утечки/throw) — Вариант B из тикета (серверный атомарный stop-and-supersede) остаётся осознанным рефактором.Checklist
Ревью — #456
fix(ai-chat): «send now» во время detached-run — авторитетный серверный stop + ограниченный ретрай 409 (#396)Вердикт: PASS ✅
Полный веер из 9 аспектов (merge-base
f8d37d89; +369). Гейт зелёный: client tsc 0,chat-thread42/42, полный client-сьют exit 0 (порог покрытия).Корректность race/retry — проверено + мутационно (клик)
attempt >= SUPERSEDE_RETRY_DELAYS_MS.length; последний 409 всплывает баннером. Мутация: убрать бонд → 34 теста падают («supersede retry is bounded»).A_RUN_ALREADY_ACTIVE(черезisRunAlreadyActive()на клоне ответа); парс-ошибка/иной 409/500 → false → НЕ ретраить (fail-safe). Мутация: убрать дискриминацию → тест падает («does NOT retry a 409 with a different body»).supersedeRetryRefармится в sendNow, разоружается на всех 4 путях (read-clear / no-send / isStreaming / unmount) — стейт чист.clone()сохраняет оригинал, ReadableStream-буфер гардирован, парс-ошибки→false, секретов нет. Architecture/conventions/documentation/simplification — LGTM (+132 минимальны, инварианты задокументированы).⛔ DROP — кодеру НЕ делать (лог калибровки)
[below-threshold]low[stability] бэкофф-setTimeoutретрая не хранится в ref и не чистится в unmount-cleanup (в отличие отreconnectTimerRef) — при unmount во время бэкоффа (≤1050мс) ин-флайт ретрай-луп доигрывает. Практически БЕЗВРЕДНО: refs разоружены (стейт чист), сообщение всё равно уходит (юзер и просил «send now»), а setState на размонтированном в React 18 — no-op. Гигиенически можно хранить таймер в ref и чистить в cleanup, но не дефект.DO/ESCALATE нет. Логика ретрая (бонд + 409-дискриминация — load-bearing safety) корректна и мутационно-держится; гонка/латч/взаимодействие верны; покрытие полное (8 новых тестов). Approve.
9 аспектов. Бонд ретрая и 409-дискриминация мутационно-проверены (subagent: 34 / 1 тест падают); латч/порядок/security подтверждены. Leaked-timer — below-threshold DROP (React 18 no-op, send доходит). Escalate нет.
View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.