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.go Warn тегируется 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 с единственным присвоением — eslint prefer-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.Startio.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-guarded removeActor (актор не сносит чужую запись при /restart-подмене под тем же ключом); метрика sessions_active пишется в defer актора (спаренность с SessionCreated независимо от дренирования канала); неблокирующая отправка actorDone (буфер 64) с fallback-самоудалением — нет взаимоблокировки Stop при массовом выходе; msgShutdown-выход graceful (bounded waitForTurnDone(10s) + spill очереди), runCancel() перенесён ДО cleanup-цикла роутера (актор с полным каналом больше не уходит в rebuild-петлю с лишним 10s ожиданием); /restart передаёт runCtx вместо context.Background() (актор, созданный в окне гонки с cleanup’ом Stop, не «бессмертничал» до idle-timeout 30 мин; nil-runCtx гасится громко), runCtx/runCancel читаются/пишутся симметрично под l.mu; spill/stash получили общие бюджеты (actorShutdownSpillBudget 5s на финальных выходах, actorRebuildSpillBudget 2s на rebuild; per-message ctx клампится на deadline) — wedged-БД не растягивает shutdown на N×3s, leftover виден в логах (Error на финальном выходе / Warn при отложении); idle-выход актора больше не теряет молчаливо непрочитанный a.ch и busyQueue (stash+spill, прежде — потеря при выборе select’ом idleTimer при готовом ящике); idleTimer.Reset после rebuild — с дренированием уставшего значения; spill-fallback routeToActor идёт на 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; composer disabled его не отражал) + реактивный 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-клиент не получал ответ. Решение — прокси-терминал (мини-ADR docs/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.tsadvancedChunks (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, миграции не нужны): идемпотентный UPDATE pending/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:busy snapshot авторитетен и для инструментов (пустой 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 по точному match call_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.tsmanualChunks (игнорировался 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-level clearMu; 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-row failed (запись в свежем 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: gitea MCP в бесконечном цикле 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, …) — deferred cancel() сразу после успешного коннекта SIGKILL-ил только что подключённый stdio-сервер (Go watchCtx убивает по 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-go v0.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, авто-resume append_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.RouteControlhandleStop), минуя 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-store pendingSeq (отрицательные 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) — до подтверждения сервером оно остаётся видимым в хвосте.