1.0.0
Changed (#362)
- Тихая деградация ctx-enrichment’ов стала видимой (#362, follow-up #361): nil-ветки
SelfTool.Execute(tools/self.go) и dedup-блокаemail_sendпосле фикса #361 недостижимы в проде (SelfState инжектится во всех пяти точках входа раннера) — теперь обе несутslog.Warn-tripwire: будущий отрыв ctx-цепочки (регрессия класса 057ce458, прожившая 2,5 месяца незаметно) сразу виден в логах; вself.goWarn тегируетсяaction. Resume-путь заперт тестомTestActor_SelfTool_SeesSelfState_OnResume— первый актор-уровневый прогонhandleResumeInterrupt: снапшот graceful-отмены (Kind=cancelled, #320) с незакрытымmy-вызовом кладётся в checkpoint-store, resume-сообщение будит ход, replay выполняетmyподresumeCtx(без инъекции SelfState тест падает исходной ошибкой; попутно выяснено и задокументировано в тесте: ask_user резюмится pending-ответом через основной путь,_resume_checkpointобслуживает approval/cancel-снапшоты). Попутно заведён: описание тулаmyобещает «Notes persist across turns within the session», а SelfState пересоздаётся каждый ход. Операционное: race-прогонinternal/agentперерос дефолтные 10 минут (805s без гонок, паритет с #359 дляserver/handler) — запускать с-timeout 20m.
Changed
- Служебные данные ZCode (
.zcode/, планы сессий агента) добавлены в.gitignore— по аналогии с.opencode/; каталог локального инструментария больше не светится вgit statusкак untracked.
Fixed (#361)
- Тул
myбольше не отвечает «Error: self state not available» на каждом ходе (#361): регрессия коммита057ce458— вhandleInboundконтекст хода (turnCtx) форкался от сырого входящего ctx ДО enrichment’ов, а в раннер передавалсяturnCtxбез переприсваиванияctx = turnCtx; SelfState, FileStates, SessionValues, Channel/RequestID, SessionKey, Workspace и llmlog-хендлер, добавляемые вctxпосле форка, физически недостижимы из иммутабельногоturnCtx(все читатели nil-толерантны — деградация была молчаливой). Восстановлена семантика «ctx = turnCtxсразу после форка; вrunner.Run/RunPlanExecute/handleResumeInterruptпередаётся обогащённый ctx» — таймаут хода и кооперативная отмена (#320) сохраняются (turnCtx остаётся предком цепочки, defer-проверка DeadlineExceeded не меняется); у форка добавлен коммент-предохранитель. Побочно восстановлено задуманное и молча отключённое с 2026-06-10: llmlog основного хода (#321/#328), read-before-edit по FileStates (#287), session-values TODO (#324), workspace-offload reduction, дедуп email, корреляция skill_runs (#353-семантика значений хода в ctx). Resume-путь (handleResumeInterrupt): вresumeCtxдобавлены SelfState (свежий экземпляр — session-notes in-memory и в чекпоинт не сериализуются), FileStates и SessionKey (симметрия с основным ходом); параметр переименован (бывш.turnCtx), оба вызова изhandleInboundполучают обогащённый ctx (наследует llmlog — как задумано #321). Субагенты (prepareChildRun): child-ран получает собственный SelfState (модель, лимит итераций после profile-override, каталог тулов ребёнка, workspace родителя по цепочке), перекрывающий родительский. Тесты:TestActor_SelfTool_SeesSelfState— ход через SessionActor, mock-провайдер вызываетmy {"action":"check"}; без фикса тест воспроизводит исходную ошибку дословно;TestPrepareChildRun_InjectsSelfState— собственный экземпляр/модель/лимит/каталог тулов у ребёнка.
Fixed (#363)
- Session-notes тула
myреально переживают границы ходов (#363): описание обещало «Notes persist across turns within the session», ноSelfStateпересоздавался на каждом ходе (NewSelfStateвhandleInbound) — notes и email-дедуп умирали на границе хода. Теперь SelfState живёт на акторе, как FileStates (#287): один экземпляр на сессию, per-turn поля (модель/лимит итераций/каталог тулов/workspace/права) перезатираются новым методомResetForTurnна старте каждого хода (override сообщения, настройки и права могли смениться), SessionNotes и email-дедуп переживают ходы. Resume (handleResumeInterrupt) получает тот же экземпляр — notes переживают и HITL-паузу./clearсбрасывает сессионную часть (новыйResetSession) вместе с fileStates во всех трёх точках сброса (хелперresetSessionState, те же границы хода — torn-state окна не изменились); topic-break намеренно НЕ сбрасывает notes (смена темы ≠ новая сессия). Email-дедуп стал сессионным: текст ошибки «already sent … in this session», Warn-tripwire — «session dedup disabled». Субагенты без изменений: собственный SelfState per child-run (child-scope). Описание тула дополнено «(until /clear)». Память ограничена жизнью актора (idle-timeout), notes — лимитом 64 ключей. Тесты:TestSelfState_ResetForTurn_KeepsSessionData(unit: per-turn поля перезатёрты, сессионные данные живы, ResetSession чистит),TestActor_SelfTool_SessionNotes_PersistAcrossTurns(акторный: note, записанный в ходе 1, читается в ходе 2; до фикса ход 2 получал «unknown key»).
Fixed (#360)
npm run lintвweb/снова проходит без ошибок (#360): тестmid-flight setPendingResendTempId does not rebind the in-flight attempt(появился в #356) объявлялlet senderRef: ChatSendс единственным присвоением — eslintprefer-const.uploadAttachments-мок вынесен в локальную константу, замыкающуюся наsenderRef; сам объект создаётся одной точкойconst senderRef = new ChatSend(deps)(колбэк мока стреляет асинхронно внутриsendMessage()— после инициализации, TDZ не нарушается). Семантика и порядок вызовов теста не менялись.
Fixed (#359)
go test -race ./internal/tray/больше не детектирует DATA RACE вTestInterruptCore_DeliversTrappableSignal(#359): тестовый харнесс назначалcmd.Stdout = &out(голый*bytes.Buffer) —os/execкопирует pipe→буфер фоновой горутиной (Cmd.Start→io.Copy), параллельно тест-горутина в poll-цикле читалаout.String()в ожиданииready; одновременные запись и чтение несинхронизированногоbytes.Buffer— гонка в самом харнессе (функционального дефекта вinterruptCoreне было, без-raceпакет зелёный). Буфер заменён наsyncBuffer(mutex +bytes.Buffer, методыWrite/Stringпод локом); логика теста не менялась. Полный прогонgo test -race -count=1 -timeout 30m ./...— без DATA RACE от tray (флаг-timeout 30mобязателен:internal/server/handlerпод инструментированием выполняется ~22 мин и не укладывается в дефолтные 10 мин).
Fixed (#358)
AgentLoop.Stop()дожидается actor-горутин до возврата (#358):routeToActorзапускалgo actor.run(ctx)внеrunWG— актор, чей idle-таймер или ход ещё исполнял БД-вызовы (ClearActorCheckpoint,MarkOrphanedSkillRuns, spill), продолжал работать после возвратаStop()и гонялся сdb.Close()вызывающего кода (gracefulShutdown → шаг database → spurious-WARN). Теперь: actor-горутины трекаются вrunWGчерезensureActor(критсекция извлечена сdefer Unlock— паника не топитl.mu); WaitGroup-контракт — структурный гвард по паттернуRouteControl(flag-check +runWG.Addв одной секцииcontrolMu,Stop()взводит флаг доWait), независимый от лейна вызывающего;actorDoneнесёт*SessionActor, удаление из роутера — identity-guardedremoveActor(актор не сносит чужую запись при/restart-подмене под тем же ключом); метрикаsessions_activeпишется в defer актора (спаренность сSessionCreatedнезависимо от дренирования канала); неблокирующая отправкаactorDone(буфер 64) с fallback-самоудалением — нет взаимоблокировки Stop при массовом выходе;msgShutdown-выход graceful (boundedwaitForTurnDone(10s)+ spill очереди),runCancel()перенесён ДО cleanup-цикла роутера (актор с полным каналом больше не уходит в rebuild-петлю с лишним 10s ожиданием);/restartпередаётrunCtxвместоcontext.Background()(актор, созданный в окне гонки с cleanup’ом Stop, не «бессмертничал» до idle-timeout 30 мин; nil-runCtx гасится громко),runCtx/runCancelчитаются/пишутся симметрично подl.mu; spill/stash получили общие бюджеты (actorShutdownSpillBudget5s на финальных выходах,actorRebuildSpillBudget2s на rebuild; per-message ctx клампится на deadline) — wedged-БД не растягивает shutdown на N×3s, leftover виден в логах (Error на финальном выходе / Warn при отложении); idle-выход актора больше не теряет молчаливо непрочитанныйa.chи busyQueue (stash+spill, прежде — потеря при выборе select’ом idleTimer при готовом ящике);idleTimer.Resetпосле rebuild — с дренированием уставшего значения; spill-fallbackrouteToActorидёт на bounded Background-ctx (сообщение shutdown-окна доезжает доactor_pending_msg, а не дропается после пред-персиста пользовательской строки). Тесты:TestStopWaitsForActors(close-guard store-декоратор: гарантия завершения actor.run до возврата Stop, отсутствие обращений к store после),TestHandlePriorityCommand_RestartActorSharesRunCtx,TestFlushBusyQueueToSpill_BudgetBounds. 11 итераций ocr review.
Fixed (#357)
- Закрытие строки
webhook_requestsскопировано по(webhook_id, request_id)(#357):UPDATE … WHERE request_id = $1не различал вебхуки — коллизия request_id между двумя вебхуками (при текущем crypto/rand 16-байтном генераторе практически невозможна; при будущих источниках id — админ-генерация, миграции, тесты — исключена и схемой, UNIQUE на request_id, миграция 061) закрыла бы чужую строку. Новый store-методUpdateWebhookRequestStatusForWebhook(ctx, webhookID, requestID, status, result)(WHERE webhook_id = ? AND request_id = ?, SQLite+PG+Dual);WebhookChannel.Sendпарсит числовой webhook_id из 2-частного ChatID (webhook:<id>, парсерwebhookIDFromChatID— легаси 3-частные/чужие формы → fallback на request_id-only) и использует scoped-обновление; хендлерHandleHook(failed/running-переходы) переведён на scoped (wh.ID известен точно). Тесты: store (свой скоуп закрывает; чужой webhook_id — no-op; контраст с не-scoped методом), канальные (терминал с чужим ChatID не закрывает чужую строку; fallback на легаси-ChatID; парсер — positives/negatives); интеграционный тест #353 адаптирован к scoped-семантике (ChatID реального вебхука). Примечание: коллизия request_id физически исключена UNIQUE constraint — тесты фиксируют достижимую семантику скоупа.
Fixed (#356)
- Текст сообщения больше не теряется при недоставке (#356):
sendMessageделалdrafts.clearДО попытки доставки — при недоставке (WS упал между isWsOpen-чеком и send, исключение/отклонение загрузки вложений) набранный текст был удалён и из поля, и из localStorage. Отправка вынесена из+page.svelteв тестируемый модульChatSend(useChatSend.svelte.ts, паттерн useChatSwitch): снапшот tuple (store, agent, raw-text, resend-корреляция) ДО цикла; восстановление при любом терминальном провале — в черновик ИСХОДНОГО агента, в поле только при неизменённом агенте и пустом поле (не затирает новый ввод и не контаминирует слот другого агента);pendingResendTempIdинкапсулирован (сеттер + drop-on-agent-switch через page-эффект). Надёжность (ocr review, 8 итераций): re-entrancy-гейт на весь ws-retry-цикл (до ~7.5s; composerdisabledего не отражал) + реактивныйisSending→ отдельный пропsendInFlight(гейтит Send-кнопку, Enter, выбор команды, file-пикер и drag-drop — НЕ показывая no-op Stop при закрытом WS); abort цикла при unmount (destroy()в page-cleanup), смене агента/store (без resurrect connectWS старого) и редактировании поля (без restore-перезаписи нового текста); clobber-гварды корреляции (Resend-клик в upload-await не перепривязывает полёт и не затирается терминалом); отклонённая загрузка вложений не стрипает их молча из отправки; обработка throw dep’а с восстановлением и диагностикой. Тесты: 15 кейсов (успех/провал/корреляция/retry-терминал/re-entrancy/upload-throw/rejected-upload/agent-switch×2/typing-during-upload/mid-flight-resend/destroy/no-store).
Fixed (#355)
- Черновики чата больше не персистят mid-IME (CJK preedit) текст (#355): во время IME-композиции
getInputText()— промежуточный preedit; дебаунс-сохранение и destroy-флаш писали его в localStorage, и после F5 восстанавливался preedit-обрубок.ChatComposerтрекаетcompositionstart/compositionend(isComposing), экспортируетisComposingNow()и колбэкиonCompositionSettled(compositionend → дебаунс-сейв финального текста) иonComposingChange(мгновенный снапшот границ композиции — к моментуswitcher.destroy()композер уже размонтирован (Svelte 5: дети раньше родителя), и destroy-гейт читает именно снапшот, а не молчаливый?? false). ВСЕ пути персиста вChatSwitchгейтятся (trackDraftInput/switch-флаши/destroy/notifyExternalTextChangeс ре-анкором skip-пары; fire-callback таймера тоже). Сопутствующее: внешние JS-записи (resend-to-composer, вставка из избранного, выбор команды) рвут композицию браузера — единая точкаbeginExternalWrite()закрывает гейт; Enter во время композиции (e.isComposing/isComposingNow()) больше не отправляет preedit сообщением (подтверждение preedit ≠ отправка); teardown-сброс гейта (compositionend может не прийти при force-removal/HMR — гейт не залипает). Итерации ocr review: отложенный settle + grace-окно заменены на синхронный settle (grace сам порождал critical-race с input-effect’ом и конфликты каналов снапшота). Тесты (8 новых): preedit не сохраняется по дебаунсу/при destroy/при switch/в notifyExternal; коммит после compositionend сохраняется; arm, переживший новую композицию, не пишет preedit на fire; композиция до истечения окна дебаунса не «протекает».
Fixed (#354)
- Повторный клик на уже выбранного агента в хедере чата больше не перезагружает чат (#354):
ChatSwitch.switchTo(тот же id)выполнял полный цикл при нулевом изменении выбора — teardown ChatStore, re-fetch моделей (api.agentModels.list) и истории (api.agentMessages), пересоздание WS-коннекта (connectWS), сбросstickToBottom, URL-rewrite — оптимистичные сообщения в полёте терялись, коннект пересоздавался. Теперь — ранний выход: pending-дебаунс-черновик same-agent досохраняется (текст в окне дебаунса не теряется), но чат не трогается. Мёртвые после раннего выходаprevAgent !== agentId-гарды упрощены (инвариант зафиксирован комментарием). Тесты: same-agent re-switch — нет re-fetch/пересоздания стора/URL-rewrite, флаш pending-черновика; смена агента работает как раньше (20 кейсов в файле).
Fixed (#353)
- Resume вебхук-хода больше не теряет корреляцию и роутинг терминала (#353): сценарий «вебхук-запрос → ask_user/approval (interrupt) → ответ владельца через REST/UI» завершался публикацией терминала ТОЛЬКО в webui-канал (
publishResumeсинтезируетInboundMessage{Channel:"webui"}без_request_id) —WebhookChannel.Sendне вызывался вовсе, строкаwebhook_requestsвисела вrunningдо sweeper’а (#352), polling-клиент не получал ответ. Решение — прокси-терминал (мини-ADRdocs/adr/2026-08-26-webhook-resume-terminal-routing.md):TurnCheckpoint.RequestID(request_id, omitempty, версия формата не меняется — поле аддитивное) штампуетсяinterruptTurn/cancelTurnиз ctx хода (tools.ContextWithRequestID, прокидка вhandleInboundрядом с channel-ctx); ctx resume-хода восстанавливается из чекпоинта (исходные канал/чат/корреляция — повторные interrupt’ы resume не теряют роутинг, жизненный цикл переживает N пауз; асимметричный легаси «RequestID без Channel» не штампуется с Warn); терминал resume-финала и видимый отказ (resume_fail, origin присваивается сразу после decode — покрывает и agent-mismatch) дублируются в исходный канал с_request_id(publishResumeTerminalProxy); webui-событие сохраняется (UI-контракт не меняется). Дедуп учитывает корреляцию: авто-resume НОВЫМ вебхук-запросом (req-B будит чекпоинт req-A на том жеwebhook:<id>-чате) проксирует обе строки — основной терминал закрывает req-B, прокси req-A. Не-вебхуковые ходы/совпадающие запросы/легаси-без-канала — no-op. Тесты: чекпоинт несёт корреляцию; повторный interrupt сохраняет её; юниты прокси (маршрутизация, все no-op-ветки, немутация исходной metadata, same-chat-new-request); интеграционный прогон прокси через реальныйWebhookChannel.Send→ строкаcompleted. Сопутствующее (сборка без WARN):vite.config.ts—advancedChunks(deprecated, WARN на каждой сборке) →codeSplitting(нативный вариант rolldown, chunk-сплит cytoscape идентичен). Features-matrix дополнен (#352/#353).
Added (#352)
- Sweeper зависших
webhook_requests(#352): строка достигает терминального статуса ТОЛЬКО при доставке терминального события — потерянная публикация (ErrBusFull, WARN вpublishOutboundLogged) или падение процесса до терминального кода оставляли еёrunning/pendingнавсегда, polling-клиентGET /hooks/{token}/statusне узнавал о завершении. Новый store-методMarkStaleWebhookRequestsOrphaned(ctx, olderThan)(SQLite + PG + Dual, миграции не нужны): идемпотентный UPDATEpending/runningстарше порога → терминальныйorphanedс человекочитаемым result; терминальные строки не трогаются. SQLite сравнивает черезdatetime()(устойчив к обоим форматам хранения — RFC3339Nano от приложения иdatetime('now')schema-DEFAULT; непарсируемое значение → NULL → строка исключается fail-safe), единый источник времени для cutoff/updated_at, проверка ошибкиRowsAffected. Точки вызова — паритет с MarkOrphaned*: старт лупа (фон, runWG-трекинг), shutdown (параллельно skill-runs recovery, bounded-wait с select), idle-таймер актора (через loop-level CAS-кулдаун 5 мин: глобальный full-table UPDATE дешевле rate-limit’ить; сбой UPDATE откатывает резервацию — окно повторной попытки открывается следующим idle, а не полным кулдауном; nil-store не продвигает кулдаун). Порог — настройкаwebhook_request_orphan_after_min(зарегистрирована в settings, дефолт 30): floor отagent_turn_timeout_minutes+ 15 мин (живой ход длиннее порога не закрывается; floor применяется только при успешном чтении — сбой чтения не задирает порог поверх явной настройки), clamp < 1 → 1 с WARN (доступно только прямым записям в settings). GetSetting-раунды и UPDATE — в отдельных bounded-окнах; паники свипа гасятся recover (старт/shutdown). Тесты: store-юнит (старые running/pending → orphaned; свежие/терминальные не тронуты; идемпотентность), agent-интеграция (старт-свип, кулдаун idle + откат при сбое, floor-математика, полный сценарий «терминальная публикация потеряна на переполненной шине → sweeper закрывает строку»).
Fixed (#351)
- Строки
webhook_requestsбольше не зависают вrunning(#351):WebhookChannel.Sendпарсил ChatID как 3-частный (webhook:<id>:<request_id>), тогда как после M-S-3 inbound-ChatID — 2-частный (webhook:<id>, requestID сознательно не входит в ChatID) — guard срабатывал всегда, и статусcompleted/failedне проставлялся никогда. Корреляция переехала в metadata:bus.MetaKeyRequestID(_request_id, новый ключ в реестре metakeys) кладётся хендлером в inbound и эхом переносится агентом (withRequestID) во ВСЕ терминальные исходящие (_turn_end: финалы streaming/content/empty, error/cancelled/interrupted/ask_user/max_iterations/loop_detected, no-provider, nil-result, таймаут хода, resume-финалы и resume-fail, push-dropped, /stop-, /clear-, /compact-результаты); канал закрывает строку только по терминальным событиям (нетерминальные и трафик без корреляции — no-op с Debug/WARN-логами; type-confusion_request_id— WARN). Провалившийся resume (runner.Run error) помечается_error— строка закрывается какfailed, а неcompleted. Пустой не-streaming финал (Content/Reasoning/parts пусты) ранее не публиковал терминального события ВООБЩЕ — теперь_turn_endс маркером_empty_finalи полным tokenInfo (включая_message_uidдля реконсиляции #314). Security (ocr review): пользовательское полеmetadataвебхук-вызова не может перезаписать служебные ключи (_request_id,_webhook_id,_webhook_name,_source_ip,_session_title— точное и case-insensitive сопоставление,isReservedWebhookMetaKey); blocklist выводится из конструктора metadata (единый источник) и пинится тестом. Надёжность терминальных публикаций (ocr review): все терминальные_ =-публики переведены наpublishOutboundLogged/publishOutboundCtxLogged— потерянная публикация (ErrBusFull) теперь WARN сsite-тегом,chat_idиrequest_idвместо молчаливого дропа (ровно тот симптом «row stuck in running», который чинит тикет). Тесты: канальные (completed/failed/no-request_id/не-терминал), handler-интеграционные (полный цикл HandleHook→шина→echo→Send→терминальный статус;_error→failed; защита метаданных), agent-level (эхо на терминале, пустой финал, таймаут, unit-хелпер). Регрессия M-S-3 (2-частный ChatID) покрыта ассертами.
Fixed (#350)
- Черновик поля ввода чата переживает обновление страницы (#350): функциональность
ChatDraftsсуществовала, но не работала —restore()нигде не вызывался, а$effectсохранения читалinputTextтолько внутриsetTimeout(Svelte 5 не трекал зависимость — сохранение срабатывало лишь при смене агента, с кросс-контаминацией A→B). Теперь проводка целиком вChatSwitch(юнит-тестируемая):switchToсинхронно флашит черновик уходящего агента ДО смены выбора, затем восстанавливает черновик нового (init → switchTo покрывает F5); дебаунс-сохранениеtrackDraftInput(300 мс, per-agent) вызывается page-эффектом на каждый ввод; сторож на fire-time (selectedAgent === agentId) исключает запись под чужой ключ при переключении до срабатывания; skip-оптимизация не переписывает восстановленное значение в тот же ключ (включая пустой restore — нет лишних записей на маунте), но любая дивергенция ретирует пару — clear→retype/edit→revert того же текста перезаписывают черновик со свежим ts (TTL 30 дней не убирает «активно используемый» черновик); unmount (destroy) флашит недебаунснутый текст вместо потери; внешние записи (resend-to-composer, вставка из избранного) — черезnotifyExternalTextChange(синхронный save + ре-анкор пары). Отправка по-прежнему очищает черновик (существующее поведение, DoD). Тесты проводки: restore при init/switch, флаш при переключении, дебаунс/коалесценция, A↔B без контаминации, same-agent re-switch, пустые черновики, agentId=null, destroy-флаш, skip/revert-циклы (17 кейсов).
Fixed (C2, #349)
- Watchdog больше не стирает reasoning/thinking живого хода (C2, #349, эпик #340): двухфазная схема — SOFT (90с тишины) меняет только индикатор (
agentPhase='unresponsive', спиннер гаснет лишь при полностью пустом состоянии), накопленный буфер размышлений НЕ трогается (долгий инструмент/медленная модель — живой ход, стирание «хода мысли» = потеря данных на ложном срабатывании); HARD (300с И отсутствие running-инструментов) — ход объявлен мёртвым: полный exit-busy сброс черезresetBusyState(буферы text+reasoning — лечит и залипаниеagentBusyпри непустомstreamingText, retry-баннер,stopInFlight, skill-runs) + мёртвый остаток сервера (running-субагенты, extraction-баннер) + dismissable-toast с причиной (не вечный error-баннер);pendingAskсознательно сохраняется (поверхность ввода, эпик #340). Точка единственного предиката живостиhasRunningTool()(agentBusy и watchdog не расходятся); running-инструмент = подтверждённая сервером жизнь (tool_end будет) — никогда не сбрасывается таймером. Сопутствующее:ws.onerror/oncloseсводятся к общемуresetConnectionState(в т.ч.stopInFlightв обоих путях — #341-контракт, и очисткаactiveToolEntries— осиротевшийrunningне блокирует hard-watchdog вечно);agent_status:busysnapshot авторитетен и для инструментов (пустойactive_toolsчистит stale-записи);recordActivityвосстанавливает фазу изunresponsiveпоagentBusy; пин-тест инварианта hard>soft.
Fixed (C1, #348)
- Устранены двойные баблы в ленте чата (C1, #348, эпик #340). ask_user: WS-событие дополнено полем
call_id(идентификатор ask_user tool_call;omitempty— обратная совместимость); клиент больше НЕ добавляет assistant-бабл с вопросом — единственный live-носитель текста формаChatAskUser(вопрос над кнопками, текстовый блокOptions:из ask.go стрипается — опции не дублируются текстом и кнопками); персистентная серверная строка с ask_user tool_call скрывается на время pending по точному matchcall_id(после ответа возвращается в таймлайн как история);restorePendingAsk(/pending_ask, F5/reconnect) восстанавливает иcall_id; безcall_idничего не скрывается (role=tool-ответы не доходят до UI — клиентская эвристика «отвечен?» невозможна и не нужна). Дедуп финального ответа: идемпотентностьstream_end/message/errorпо нормализованному тексту в рамках хода (повторный stream_end-snapshot, терминальныйmessageпосле стрима из cancelled/interrupted/error-веток больше не создают второй бабл); окно дедупа живёт доthinking/turn_end/resetStateи переживает WS-reconnect (поздние реплеи прерванного хода подавляются — merge уже принёс авторитетные строки); whitespace-only payload не сбрасывает pendingAsk и не затеняет серверный snapshot; диагностика подавления — console.debug. Сопутствующее (сборка без WARN): кураторский билд highlight.js (lib/core + 38 языков, изолированныйnewInstance(), явные алиасыobjective-c; чанк чата 1114→349 kB) вместо полного пакета (~190 грамматик);vite.config.ts—manualChunks(игнорировался rolldown-vite) заменён наadvancedChunks(cytoscape в отдельном чанке 435 kB, все чанки <500 kB); plaintext-фенсы (```text/plain/plaintext) рендерятся без highlightAuto; исправлена предсуществующая ошибка линтера вroutes/chat/+page.svelte(bare expression в$effect).
Changed (B3, #347)
/clearостанавливает активный ход перед удалением истории (B3, #347, эпик #340):handleClearпереиспользует graceful-механику /stop (RequestGracefulCancel → WaitForIdle) в bounded re-check-цикле (3 wait-бюджета, бесплатная перепроверка busy/актора после каждого ожидания — межходовое окно busyQueue больше не пропускает DELETE мимо только что начатого хода); /clear сериализован loop-levelclearMu; actor-level fallback-cancel — не чаще одного раза на актора; ход, не остановившийся за окно, — очистка ОТМЕНЯЕТСЯ с явным_clear_status=turn_still_running+_clear_failedв терминальном событии (история не тронута). Delete-ctx — от runCtx с тихим shutdown-путём (без ложной «context canceled» пользователю, Stop прерывает зависший DELETE). Выделены общие хелперыstopWaitTimeout/graceExtendWait(единая формула окна для /stop и /clear, settings-lookup вне цикла). Контракт idle-очистки (#287 msgResetFileStates) сохранён.
Added (B2, #346)
- Надёжный персист финального ответа ассистента (B2, #346, эпик #340):
persistFinalAssistant— ретраи с backoff (200мс/500мс, lock-защищённые паузы) в свежем bounded-окне 10с, укоренённом в loop-ctx (шатдаун прерывает,/stopне убивает запись — ходовой ctx к финалу мог быть отменён); без ретраев при мёртвом окне (ошибка обёрнута с причиной отмены для триажа). Провал после исчерпания попыток более не молчит: терминальное_turn_endнесётpersist_failed(прокинут в WS-событие), а_message_uidне эмитится — клиент помечает стрим-баблы текущего хода «Не сохранено (ошибка БД)» (до границы user-бабла; toast), реконсилиация #343 исключает помеченные записи из role+content-fallback (байт-совпадение с чужим ходом не съедает маркер), неподтверждённые записи переживают re-fetch. Reasoning-only финалы при провале тоже доставляют терминальное событие. Регрессия: обычный ход — ровно одна запись.
Added (B1, #345)
- Устранение молчаливых потерь на inbound-шине (B1, #345, эпик #340):
BaseChannel.HandleMessageвозвращает ошибку публикации вместо глотания (ErrBusFull/ErrSenderDeniedсентинелы); WS-путь отвечает явным nack —error-событие сclient_temp_idи фиксированным detail (message not delivered/message rejected), адресно в соединение отправителя (без fan-out на другие вкладки); webhook-путь при переполнении шины отвечает 503 и помечает request-rowfailed(запись в свежем bounded-контексте, неr.Context())._error-события несутclient_temp_id; классификацияpersist_failed(#344: сообщение в очереди, БД-персист не удался) отличает transient-предупреждение от nack. Клиент: nack помечает оптимистичный баблdelivery_failed(маркер «Не доставлено» + toast, текст сохранён и переотправляем), persist_failed — только warning без приглашения к повтору; повторный nack идемпотентен; resend (кнопка «Переотправить» передаётclient_temp_id) снимает маркер точным id-матчем;message_persisted-swap сбрасывает маркер защитно. REST-путь: 503-регресс-тест.bus.ErrBusFullэкспортирован;SetPublishTimeout(test-only, generation-guarded) для кросс-пакетных тестов.
Fixed (#336)
- stdio MCP-серверы убивались сразу после коннекта — регрессия #308 ( — P1; инцидент продакшена 192.168.2.104:
giteaMCP в бесконечном циклеtransport closed→ reconnect, ~1 мёртвый child-процесс в минуту + накопление зомби[gitea-mcp] <defunct>, вызовы тулов падалиMCP tool error: mcp call "issue_read" on "gitea": transport error: transport closed).connectBoundedиз #308 выполняетsess.ConnectподattemptCtx = context.WithTimeout(ctx, 60s); этот же ctx уходил вclient.Start(ctx), а mcp-go порождает subprocess черезexec.CommandContext(attemptCtx, …)— deferredcancel()сразу после успешного коннекта SIGKILL-ил только что подключённый stdio-сервер (GowatchCtxубивает по ctx.Done даже доWait); следующий ping/вызов получал EOF → «transport closed», health-loop реконнектил — цикл замыкался навсегда. Тесты #308 покрывали только streamable_http. Фикс:client.Start(context.WithoutCancel(ctx))— время жизни subprocess отвязано от attempt-дедлайна, завершается только штатно черезClose()(SIGTERM → SIGKILL); bounded ctx сохранён на handshake (Initialize/SetLevel/ListTools/ListResources/ListPrompts) — гарантия #308 не тронута: зависший stdio-сервер по-прежнему фейлится за bound, failure-путь убивает child черезclient.Close(). Проверено: привязка ctx к процессу не изменилась в mcp-go вплоть до v1.0.0-beta.1 — фикс необходим на нашей стороне в любой версии. Попутно: апгрейдmark3labs/mcp-gov0.52.0 → v0.58.0 (последняя стабильная; накопленные фиксы Close/zombie/FD-leak в транспортах). Тесты (+2, re-exec паттерн с реальным stdio MCP-сервером): сессия жива и отвечает на Ping ПОСЛЕ cancel и истечения attempt-дедлайна (красный на pre-fix с точной продовой ошибкой «transport closed»); нереагирующий на Initialize stdio-сервер фейлится в пределах bound с DeadlineExceeded в цепочке.
Added
- Детерминированная реконсиляция сообщений по
client_temp_id(A3, #343, эпик #340):mergeServerMessagesпереписана на неразрушающее слияние — деструктивный порогfirstRecentIdудалён; серверные строки ключуются по id, оптимистичные — поclient_temp_id(замена на серверную запись «на месте», спаренная с эмиссией — id-коллизия не глотает запись) с fallback на role+content-эвристику (только для серверных строк без temp-id — двойной ack исключён); неподтверждённые записи никогда не выбрасываются; серверная страница выигрывает у локальных копий (свежесть); идемпотентность повторных merge. REST/GetRecentUIMessagesотдаютclient_temp_id(миграция 114, #344) — реконсиляция работает и по эхуmessage_persisted, и по re-fetch страницы.
Added (A4, #344)
- Немедленный персист пользовательского сообщения при приёме (A4, #344, эпик #340): при busy-акторе
routeToActorфиксирует user-строку в БД до постановки в очередь/спилл (единыйbuildUserContent, реальный монотонный id) и эхом возвращаетmessage_persisted {client_temp_id, message_id}— оптимистичная запись клиента заменяется на серверный id на месте, без «моргания» на промежуточномturn_end. Гварды двойного персиста:handleInbound,coalesceBusyMessage, авто-resumeappend_user_messageпропускают свою запись при метке_persisted_msg_id. Миграция 114 (SQLite+PG):messages.client_temp_id+ прокидывание в AddMessage/scan/REST; meta-ключи централизованы (bus.MetaKey*). Ошибка записи — громкая (лог + клиентское error-событие с корреляцией), retry вhandleInboundостаётся recovery-путём; resume-сообщения и системные каналы не прерсиствятся; регресс-гарантия одиночного хода (1 запись) сохранена. Клиент:client_temp_idгенерится на отправку, едет в payload и оптимистичной записи; эхо заменяет id (integer-гварды, malformed-эхо логируется);/stop-детект до отправки кадра; rollback оптимистичной записи при send-throw.
Added (A1, #341)
- «Стоп» как управляющее событие, а не чат-сообщение (A1, #341, эпик #340): WS control-кадр
{"type":"control","action":"stop"}маршрутизируется в read-loop напрямую в приоритетную команду (ControlRouter.RouteControl→handleStop), минуяHandleMessage/шину/персист чат-текста; контракт ответа_turn_end+_stop_status(stopped/still_running/already_idle) сохранён. Клиент:ChatStore.stopAgent()шлёт control-кадр без оптимистичного бабла/stop, идемпотентен до терминального события/reconnect, не меняет фазу хода и не продлевает watchdog; литеральный/stopиз композера форвардится в control-канал до отправки message-кадра. Текстовый/stopостаётся серверной легаси-совместимостью (email/webhook). WaitGroup-контракт остановки: флагcontrolShutdown+ мьютекс координируютrunWG.AddсStop()→Wait.
Added (A2, #342)
- Клиентская хронологическая сортировка рендера чата (A2, #342, эпик #340): единый детерминированный источник порядка —
$derived-проекцияsortedMessages(sortMessagesForRender: серверные id по возрастанию, оптимистичные записи — всегда в хвосте);MessageListрендерит только из проекции. Фейковый float-id (Date.now()+Math.random()) заменён монотонным per-storependingSeq(отрицательные id) — стабильные ключи{#each}без коллизий. web/src/lib/stores/chatMerge.ts— чистые (не-реактивные) хелперы проекции:sortMessagesForRender,mergeServerMessages,isServerMessage.- Merge
mergeServerMessages: оптимистичные записи больше не выбрасываются деструктивным порогом — неподтверждённые сохраняются в хвосте; подтверждённые (role+content-эвристика с учётом кратности, до детерминированной замены поclient_temp_idв #343) заменяются серверными без дублей; guard от малформ-границы (NaN/non-positive first id) не даёт молча потерять локальную историю; дедуп id + защита от дублей id внутри серверной страницы. - Пагинация «загрузить раньше» (
useChatSwitch.loadMore) берёт курсор изoldestServerId— оптимистичные id не утекают вbefore=.
Fixed
- Порядок сообщений в UI больше не зависит от порядка прихода/вставки в массив: любая нештатная последовательность WS-событий или merge отображается хронологически (эпик #340, этап A).
- Оптимистичное пользовательское сообщение не исчезает на промежуточном merge (reconnect, «Новая тема»,
turn_end) — до подтверждения сервером оно остаётся видимым в хвосте.