Unreleased

Added

  • Non-destructive compaction: soft-delete через флаг archived. AutoCompactService раньше физически удалял сообщения из таблицы messages при компакции диалога, оставляя хвост keepLast=8 + одну system-сводку [Resumed Session]. Это разрушало видимую пользователю историю чата: в UI отображалось <50 сообщений, пагинация при скролле наверх не срабатывала (грузить было нечего), поиск по старым сообщениям не работал, а восстановить удалённое было невозможно. См. ADR docs/adr/2026-06-30-non-destructive-compaction.md.
  • Миграция 099 (SQLite + PG, обратимая): колонка messages.archived BOOLEAN NOT NULL DEFAULT FALSE (существующие строки видны как активные) + composite-индекс idx_messages_agent_archived_id(agent_id, archived, id) для контекстных запросов + ANALYZE для свежей статистики планировщика. PG-вариант идемпотентно добавляет agents.compacted_at (IF NOT EXISTS) — hotfix для прод-инсталляций, где миграция 049 применилась не полностью (SQLSTATE 42703).
  • Развязка «LLM-контекст» vs «UI/поиск». Контекстные методы LLM (GetMessages, GetRecentMessages, GetMessagesAfterID, GetMessagesAfterIDInScope) фильтруют archived=FALSE — промпт остаётся компактным. UI/поиск-методы (GetRecentUIMessages, GetUIMessagesBefore, GetMessagesByTimeRange) фильтр НЕ применяют — видят всю историю. Explicit-access side-channels (GetMessageByID, GetFavorites, GetLatestSummary) намеренно отдают archived — осознанный design choice (explicit reference пользователя/LLM, избранное, summary-маркер выживает компакцию).
  • AutoCompact non-destructive. ArchiveMessagesBefore/ArchiveMessagesBetween (UPDATE messages SET archived=TRUE WHERE ...) заменяют DeleteMessages* в compactAgent — физическое удаление устранено, данные не теряются. Логика summary / keepLast без изменений.
  • Фикс молотьбы компакции. Колонка agents.compacted_at раньше только писалась, но не читалась в модель AgentrunOnce передавал compactedAt=nil, защита «уже компактнул?» (MaybeCompact:120) не срабатывала, и неактивный агент компактился каждый тик (лишние LLM-запросы на summary). Новый batch-метод GetAgentsCompactedAt (один query, audit C-S-4) читает compacted_at для всех агентов; runOnce передаёт реальное значение → повторные тики корректно пропускаются. Обработка context.Canceled/DeadlineExceeded — graceful shutdown без WARN-шума (consistency с ListAgents).
  • Проверки: 16 новых тестов в internal/memory/autocompact_archive_test.go (soft-delete, фильтрация контекстных методов, безфильтровость UI-методов, TestCompactAgent_NonDestructive — archived-assertions + неизменный count, TestMaybeCompact_RespectsCompactedAt — regression-тест защиты от молотьбы, batch-чтение). go test ./internal/... — 30 пакетов зелёно; golangci-lint — 0 замечаний; make build-cross — 6 бинарников; цикл ocr review — валидные замечания устранены, невалидные обоснованно отклонены.
  • AutoCompact: событийный триггер по парам+токенам вместо TTL-таймера. AutoCompactService запускал компакцию каждые 30 минут по таймеру, безотносительно к наполнению диалога. Это порождало два патологических кейса: в быстром диалоге окно разрасталось сверх бюджета раньше, чем срабатывал тик; в медленном — триггер молотил впустую, генерируя лишние LLM-запросы. См. ADR docs/adr/2026-06-30-autocompact-event-driven.md.
  • Гибридный триггер. Компакция запускается асинхронно после завершения хода агента (actor.gobgWG-горутина → MaybeCompactAsync), а не по таймеру. Решение: historyTokens ≥ budget.History × softThreshold% (по умолч. 70%, token-trigger) ИЛИ pairsCount ≥ keepPairs + 4 (pair-trigger, hard-cap). Token-trigger использует per-agent ContextMaxTokens через ComputeBudget. Pair-trigger защищает от множества коротких пар. Per-agent TryLock (sync.Mutex): повторный триггер поверх идущей компакции = no-op.
  • Парная модель. keepLast переинтерпретирован как keepPairs (по умолч. 8): хранятся последние 8 пар «user→assistant», а не 8 сырых строк. topic_break/interrupted сбрасывают счётчик. Промежуточные assistant с tool_calls в БД не пишутся (только финальный текст) — пара вычисляется тривиально. Старые [Resumed Session]-маркеры архивируются при новой компакции (ArchiveMessagesByIDs) — в активном окне всегда ≤1 маркер.
  • Починена проводка настроек. SetResolveConfig/SetBudgetResolver вызваны из main.go → UI-настройки keepPairs/protectFirstN/softThreshold (per-agent compaction_config JSON) реально доходят до AutoCompact через resolveAgentConfigagent.ResolveCompactConfig(). Раньше SetKeepLast/SetResolveConfig были определены, но не вызывались — AutoCompact работал на жёстких константах.
  • Удалено: AutoCompactService (service+test), MaybeCompact (TTL-логика), GetAgentsCompactedAt (batch, purpose-built под сервис), setting autocompact_ttl_minutes (breaking). CompactAgent сохранён для ручной команды /compact.
  • Проверки: 11 новых тестов (autocompact_pairs_test.go: groupIntoPairs/completePairs с перемешанными role и tool_call_history, token-trigger, pair-trigger, no-trigger, topic_break reset, TryLock, enabled-gate). go test ./internal/... — 28 пакетов зелёно; golangci-lint — 0 замечаний; make build-cross — 6 бинарников.

Changed

  • Унификация высоты элементов в ChatHeader. Кнопочные элементы шапки чата (выбор модели, действия «Новая тема»/«Данные»/«Избранное»/«Задачи», мобильный kebab, кнопка агента) имели разную высоту (28–40px), из-за чего ряд выглядел «рвано» в десктопе и на мобильных. Все кнопки приведены к единой высоте 36px (совпадает с hamburger и UserMenu): .ch-action-btn/.ch-model-btn переведены с вертикального padding на фиксированный height: 36px (padding: 0 12px, box-sizing: border-box); иконка кнопки модели w-3.5 h-3.5w-4 h-4 (как у action-кнопок); аватар агента size={40}size={36} (в dropdown-кнопке и single-режиме) — теперь совпадает с аватаром UserMenu. npm run check — 0 ошибок.
  • Скрытие аватарок в списке сообщений чата на мобильных экранах. На узких экранах (<768px, брейкпоинт md) аватарки в строках сообщений (40px изображения + 12px gap = 52px) съедали ценную горизонтальную ширину под тело сообщения. Медиа-запрос в app.css (max-width: 767px): .msg-row > :first-child { display: none } скрывает аватар и gap: 0 убирает отступ — действует сразу на три компонента с идентичной разметкой .msg-row → Avatar + .msg-content: ChatMessage (обычные сообщения), StreamingMessage (стрим ответа), ChatThinking (индикатор «печатает»). Надпись с именем автора над сообщением (.msg-author-name внутри .msg-content) остаётся видимой на всех экранах. Отступы блоков tool-итераций и разделителей тем в MessageList.svelte (pl-[52px]) сделаны адаптивными: pl-0 md:pl-[52px] — на мобиле выровнены по левому краю тела сообщения, на десктопе сохранена сетка с аватаром. Системные сообщения cron/heartbeat не затронуты. npm run check — 0 ошибок; цикл ocr review — валидных замечаний нет.

Fixed

  • Три бага UX чата при работе агента. web/src/lib/stores/chat.svelte.ts — три связанных визуальных бага, проявлявшихся до выдачи финального сообщения:

  • Индикатор «Агент печатает» залипал. Обработчик stream_end не сбрасывал isThinking/thinkingAgentName (в отличие от message и turn_end), полагаясь на последующий turn_end, который мог задержаться. Теперь stream_end сбрасывает их безусловно. Watchdog дополнительно очищает застрявший isThinking при переходе в unresponsive (если нет активного стриминга/тулов).

  • Аватарка пользователя не отображалась на своем сообщении во время работы агента. sendMessage/answerAskUser добавляли оптимистичное сообщение с user_id: 0ChatMessage.svelte считал сообщение «не своим» → avatarVersion.url('users', 0, …) возвращал undefined → рисовался только fallback-инициал. Теперь используется currentUser()?.id из auth store. На мобильных аватар скрыт CSS, баг заметен на десктопе (≥768px).

  • Пропажа своего сообщения при рефреше во время работы. Race window между HTTP-отправкой и персистом в БД: при рефреше в этом окне GET /messages ещё не содержал пользовательское сообщение. Новый хелпер ensureUserMessageSynced делает re-fetch при обработке thinking/agent_status:busy, если последнее локальное сообщение не user. К моменту этих событий БД-запись гарантированно выполнена. В обычном потоке (оптимистичное сообщение уже добавлено) — no-op, лишних запросов нет.

  • Проверки: 8 новых тестов (stream_end сбрасывает isThinking; watchdog timeout; sendMessage/answerAskUser с реальным user_id; thinking триггерит re-fetch; agent_status:busy триггерит re-fetch). npm run check — 0 ошибок; npm test — 571/571; цикл ocr review — валидных замечаний нет.

  • Единый компонент карточки агента. Карточки агента на страницах /agents, /dashboard и /settings/agents приведены к единому виду — везде используется один self-contained компонент AgentCard.svelte на базе ResourceCard:

  • Layout: везде сетка 2 колонки (включая /settings/agents, ранее — вертикальный список).

  • Структура: базовый блок (аватар, имя, бейджи autonomy_level/thinking_level, описание, 2-колоночная сетка метрик с прогресс-баром контекста и счётчиками провайдеров/моделей) + expandable «Детали» (realtime extraction, лимит контекста, compaction, workspace, даты). На /settings/agents секция раскрыта по умолчанию.

  • Перенос логики: хелперы ctxPctFor/compactionConfigStr из settings/agents перенесены внутрь AgentCard с защитой от NaN/Infinity и ограничением длины compaction.

  • Скелетон AgentCardSkeleton обновлён под новую структуру.

  • Доступность: прогресс-бар контекста получил role="progressbar" + ARIA-атрибуты.

  • Унификация карточек на 7 оставшихся страницах /settings/*. Продолжение подхода: страницы /settings/{audit,llm-log,metrics,system,users,log-settings,advanced} были оформлены разнородно — полностью на сырой разметке (0 совпадений по SettingsPageHeader/ResourceCard/Button/Badge/Modal/INPUT_CLASS), с собственными кнопками/бейджами/модалками/баннерами ошибок/скелетонами в каждой. С бэкенда приходили поля, не показанные на UI (LLMLogEntry.{session_id,error_message,prompt_tokens,completion_tokens,cached_tokens,created_at}, ToolAuditEntry.session_id, CronAuditEntry.cron_job_id, MetricsSnapshot.kg.same_as_links_total + per-agent queue_*, MemoryAgentMetrics.{access_zero,unused_30d,by_subject,updated_at}, SystemInfoResponse.{tray_pid,has_tray,supervised,managed_externally,uptime_human}, UpdateStatus.{supervised,auto_update_supported}, UpdateLastRollback.{at,rolled_back}, SecurityConfig.whitelist, User.theme, ProfileData.{irritants,theme}, ReindexJob.{old_dim,old_model,started_at}).

  • Новые общие ui-компоненты: Toggle.svelte (switch role="switch", дизайн-токены вместо text-green-600) и ParameterRow.svelte (единый виджет AdvancedParam: type-based контрол boolToggle / int/float→number INPUT_CLASS / иначе warning, Badge «перезапуск»/«изменено», кнопка «Сброс», inline-валидация aria-invalid + aria-describedby). Helpers вынесены в ParameterRow.helpers.ts (paramKind, isBoolOn, canonBool, isValidNumber, numericOrUndef, clampNumber) и покрыты unit-тестами (14 тестов).

  • ui/Card.svelte расширен: добавлены опциональные сниппеты badges/details/actions (последний — переименование footer, сохранён как deprecated-алиас); padding md возвращён к p-4 sm:p-6 после кратковременной регрессии.

  • ui/Button.svelte: добавлен variant="warning" (bg-warning hover:bg-warning/90 text-warning-text).

  • ui/Modal.svelte: добавлен проп subtitle (show под заголовком + включается в aria-label для скринридеров); body получил max-h-[80dvh] overflow-y-auto.

  • /settings/audit (сохранены таблицы для плотности): SettingsPageHeader + Button/Badge/INPUT_CLASS/EmptyState; ExpandableToggle теперь единый для всех 4 вкладок (раньше в logs самописная кнопка /); levelVariant различает ERROR/WARN/INFO/DEBUG (раньше только ERROR/WARN); ошибки загрузки данных больше не глушатся (раньше молча сбрасывались в []); error очищается при переключении табов и при успешной перезагрузке; role="alert" на баннере. Скрытые поля (ToolAuditEntry.session_id, CronAuditEntry.cron_job_id) — в expandable.

  • /settings/llm-log (сохранена таблица): самописный toggle заменён на ui/Toggle (убран жёсткий text-green-600); разделены pageError (загрузка) / settingsError (не удалось получить статус логирования) / toast (ошибки toggle/copy через toast.error для симметрии с успехом); сырые табы «Запрос»/«Ответ»/«Копировать JSON» → Button (ghost/sm); бейджи стратегии/статуса → Badge; 6 скрытых полей LLMLogEntry (session_id, error_message, prompt_tokens, completion_tokens, cached_tokens, created_at) — в expandable строки; error_message дополнительно показывается при status==='error'; имя агента на soul_name || name (как в audit); {#each} keyed by entry.id вместо idx; tokenStr различает 0 (cache miss) и undefined.

  • /settings/metrics: KPI-ряд оставлен; 6 bespoke-карточек графиков → каждая в ResourceCard (title=секция, meta=источник/last_call_at/счётчики, children=виджет Donut/Bars/KV, details=скрытые поля бэкенда kg.same_as_links_total/extraction_errors/per-agent queue_pending/queue_running/queue_failed/memory.agents[*].access_zero/unused_30d/by_subject/updated_at); селект агента → INPUT_CLASS; min/max latency-спарклайна вынесены из .map (O(n²)→O(n)); stroke через toneVar.amber вместо хардкода var(--c-amber); role="alert" на баннере.

  • /settings/system + 6 дочерних компонентов полностью рефакторинг: +page.svelteSettingsPageHeader + Button size="sm"; SystemInfoCardCard с dl-сеткой + details (tray_pid, has_tray, supervised, managed_externally, uptime_human); UpdatesCardCard + Button/details (supervised, auto_update_supported, platform), сырая confirm-модалка → ui/Modal + disabled на install-кнопке; SystemActionsCard + Button primary/danger с иконками + ui/Modal (сырые 2 модалки → ui/Modal), managed_externallyBadge warning; SecurityCardCard + INPUT_BASE-моно для textarea (вместо bespoke-класса) + Badge invalid + details для сырого whitelist, text-white заменён на text-accent-fg; DiagnosticsCardCard + Button; RollbackBanner → warning-баннер с Badge + Button ghost, показаны at (formatDateTime) и rolled_back.

  • /settings/users: bespoke <table> → список ResourceCard (icon=инициал/аватар, badges=роль Badge purple=default, meta=ID #… · Создан …, details=avatar_path/theme), действия Button ghost sm (Сделать/Снять админ, Профиль, Изменить, Сменить пароль, Удалить=danger); 3 сырые модалки (create/edit, change password, profile) → ui/Modal с INPUT_CLASS (вместо px-4 py-3); AvatarCrop вынесен за пределы ui/Modal как отдельный оверлей; добавлены скрытые поля ProfileData.irritants/theme в profile-модалку; закрытие модалки после успешного loadUsers (не до); role="alert" на баннере.

  • /settings/log-settings: сырая таблица → Card + список ParameterRow (заменил bespoke number-input + bespoke бейджи «изменено»); EmptyState; валидация различает int (целое) и float (раньше все параметры считались числами, что тихо теряло данные при type='bool').

  • /settings/advanced: 8 сырых accordion-групп → ResourceCard(detailsOpen) с ParameterRow в details (восстановлена сворачиваемость групп); самописный toggle-switch → Toggle; ReindexSectionCard + Button/Badge/INPUT_CLASS, скрытые old_dim/old_model/started_at в details; убран inline-дубликат toast (сырой <p> «Сохранено»); восстановлена bool-канонизация (canonBool) на load/reset/baseline (регрессия при первоначальном извлечении ParameterRow); добавлена fallback-группа «Прочее» для параметров, чья group не описана в конфиге (чтобы новые группы бэкенда не терялись молча); role="alert" на баннере.

  • Единые законы UX карточек (теперь на 15 страницах settings/*): заголовок — только SettingsPageHeader; карточка-сущность — ResourceCard; карточка-форма/инфо — Card; кнопки — только ui/Button (никаких сырых <button> в шаблонной части); бейджи — только ui/Badge; модалки — ui/Modal, подтверждения — ConfirmModal; поля ввода — INPUT_CLASS (или INPUT_BASE+border для ошибок); пустые состояния — EmptyState; баннер ошибки — единый класс bg-danger-bg border-danger-border text-error-text + role="alert" + type="button" на close; даты — formatDateTime/formatDate; все поля бэкенда видимы (основные в meta/таблице, второстепенные в expandable «Детали»).

  • Проверки: ocr review — 3 итерации, все валидные замечания устранены, невалидные обоснованно отклонены; npm run check — 0 ошибок; npm test — 551/551 (+14 новых для ParameterRow.helpers); make build-webui — OK.

  • Версия: +PATCH (v0.150.3).

  • Ревизия AGENTS.md по замечаниям ревью. Восемь правок + перераспределение замечаний по логичным разделам:

  • Миграции БД: убран устаревший хардкод числа миграций («77» при фактических 97). Раздел переписан как таблица двух каталогов — internal/store/migrations/ (SQLite, go:embed migrations/* в migrations.go) и internal/store/pgmigrations/ (PostgreSQL, go:embed pgmigrations/*.sql в pgmigrations.go). Актуальный номер проверяется через ls по обоим каталогам (а не хранится в AGENTS.md); новая миграция добавляется в оба каталога одновременно под одним номером; парность SQLite↔PG проверяется тестом в internal/store/migrations_test.go.

  • Версионирование: в таблицу инкрементов добавлена строка «Изменение существующего функционала / правки визуальной части (UI) → +PATCH».

  • Правила для агента: добавлено общее правило — любое редактирование кода сопровождается созданием issue (открыть ДО → реализовать → закрыть с итоговым комментарием). Пункты про ADR и docs/todo.md явно увязаны с обязательным созданием issue с приоритетом (принятый ADR → одно или более issue; запись в todo.md без issue не допускается).

  • Код-ревью (ocr review): развёрнут в 5-шаговый цикл — просмотреть все замечания → проверить валидность каждого → валидные устранить → невалидные обоснованно отклонить → повторять цикл пока валидные замечания не закончатся.

  • Пост-условие (тесты): уточнено — если «красный» тест не связан с текущими изменениями, всё равно разбираться с ним и с кодом, который он проверяет; чинить корневую причину, а не игнорировать падение.

  • Работа с задачами через Gitea Issues: метку agent:task ставит только пользователь (агент самостоятельно её НЕ вешает — наличие метки значит «задача проверена и готова к выполнению»); приоритет priority:P0→P3 обязателен для каждой создаваемой задачи; при авто-создании issue (по запросу из чата) метка НЕ ставится, приоритет ставится. Разрешено противоречие между старым разделом «Авто-создание задач» (ставил метку) и правилом «только пользователь».

Removed

  • Подсистема reranker полностью удалена. В продакшене (bge-reranker-large через Ollama) постоянно сыпался спам ollama reranker score failed из-за бага llama.cpp (GGML_ASSERT(n_outputs_max <= cparams.n_outputs_max) failed) при работе с BERT-семейством. Помимо этого, в коде reranker’а были баги: circuit breaker не открывался на HTTP 5xx (isRequestLevelError матчил только net.Error/context.*), из-за чего warn логировался на каждый документ (7-18 фактов + 1-4 чанка за RAG-turn); при открытом breaker Rerank возвращал nil, nil → call site терял факты вместо fallback на retriever-результаты. После ревью польза reranker’а в текущем виде признана минимальной: гибридный RAG-пайплайн (vector + FTS + RRF + time decay + importance + MMR) покрывает ~90% того, что даёт reranker, а topK 7-18 фактов после RRF+decay+MMR уже хорошего порядка. LLM-reranker нестабилен как скорер (галлюцинации JSON, путаница шкал), Ollama-reranker капризен для desktop-продукта. Решение: полное удаление подсистемы.
  • Удалено: пакет internal/reranker/ (7 файлов); поле reranker в AgentLoop/ContextBuilder/ModelHandler/MemoryHandler/PromptPreviewHandler; методы SetReranker/Reranker; хелперы resolveReranker/searchResultsToRerankDocs/rerankResultsToSearchResults; rerank-блоки в buildMemoryContext (RAG) и MemoryHandler.Search (/memory/search) — заменены на truncation to topK; HTTP-роуты GET/PUT /models/reranker, POST /models/reranker/test; функция initReranker в main.go; конструктор NewContextBuilderWithEmbedderAndReranker (всюду заменён на NewContextBuilderWithEmbedder); колонка agents.reranker_config; настройка settings.reranker_model_id; Reranker-карточка в UI /settings/models + filter chip + badge-color + dropdown option; API-методы api.models.getReranker/setReranker/testReranker и типы RerankerProviderInfo/RerankTestResult; allowlist kind для провайдеров убрано значение "reranker". Поля reranker и llmProvider убраны из MemoryHandler (последний использовался только resolveReranker).
  • Миграция 097 (SQLite + PG, обратимая): ALTER TABLE agents DROP COLUMN reranker_config, DELETE FROM settings WHERE key = 'reranker_model_id', UPDATE models/llm_providers SET enabled=false, kind='llm' WHERE kind='reranker' (конвертация, не удаление — внешние ссылки не ломаются; пользователь может удалить вручную через UI).
  • Документировано. ADR docs/adr/2026-06-29-reranker-removal.md; прежний ADR 2026-05-06-reranker.md помечен Superseded; docs/reranker-implementation-plan.md удалён; docs/roadmap.md, docs/webui-structure.md обновлены.
  • Версия: +MINOR (v0.150.0). БД-миграция обратимая, внешнего API-контракта нет, продукт не публичный — MAJOR-бамп не требуется.
  • Пост-условие на сервере: после деплоя миграция 097 выполнится автоматически — спам в аудит-логах прекратится, модель id=34 деактивируется. Ручных действий не требуется.

Fixed

  • Унификация карточек на /settings/{agents,skills,mcp,subagents}. Продолжение подхода: четыре страницы настроек были оформлены разнородно — разные ручные заголовки (<h2> + кнопка/ссылка вместо SettingsPageHeader), разные карточки (отдельный AgentCard.svelte-тайл в сетке 3×N на agents vs ручные <div> на skills/mcp/subagents vs ResourceCard), контролы то кнопки то текстовые ссылки то иконки-SVG в разных углах карточки, бейджи местами ручные bg-badge-* спаны + сырой tailwind (text-yellow-600, text-green-600), две ручные модалки (agents, mcp) вместо ui/Modal. С бэкенда приходили поля, не показанные на карточках (Skill.content/metadata/owner_agent_id/created_at/updated_at, MCPServer.env/headers/oauth_client_id/oauth_scope/created_at/updated_at, SubagentProfile.system_prompt/output_contract/mcp_whitelist/max_iterations/timeout_seconds/sort_order/created_at/updated_at, Agent.autonomy_level/thinking_level/context_max_tokens/realtime_extraction/compaction_config/workspace_path/created_at/updated_at).

  • ResourceCard.svelte расширен: добавлен опциональный пропс href — если задан, шапка карточки (icon + title + badges + meta) оборачивается в кликабельную <a> (навигация на страницу ресурса), expandable «Детали» и футер actions остаются вне ссылки. Header-контент вынесен в локальный snippet headerContent для бездублирующего рендера в обеих ветках; корневой div получает hover:border-field cursor-pointer при наличии href. Полностью обратно совместим (все текущие страницы не передают href).

  • /settings/agents переведён с AgentCard-тайловой сетки на вертикальный список ResourceCard с href="/agents/{id}": аватар в icon-слоте, бейджи autonomy_level/thinking_level (Badge), meta — контекст % (с цветовой точкой bg-success/bg-warning при ≥80) · счётчики провайдеров/моделей · возраст (formatAgentAgeShort)/updated_at (formatRelative), description в children, в «Детали» — context_max_tokens, realtime_extraction, default_provider_id/default_model_id, compaction_config (stringify, если не {}), workspace_path, даты. AgentCard.svelte НЕ удалён — используется на /dashboard и /agents.

  • /settings/skills: ручной <div>ResourceCard (бейджи builtin purple / always success, meta — описание + приватный агент, в «Детали» — content превью line-clamp-6 + «полностью в редакторе», metadata font-mono, даты), футер actions с Button «Изменить» (goto) + «Удалить» (скрыт для is_builtin). EmptyState с actions. Создание — Button + goto('/settings/skills/new').

  • /settings/mcp: ручной <div>ResourceCard (icon-квадрат с SVG по дизайн-токенам, бейджи type info + OAuth-статус success/warning/danger через oauthBadge() — заменил сырой tailwind text-yellow-600/text-green-600, meta — command/args или url font-mono + таймаут, в «Детали» — env/headers font-mono break-all как в providers extra_*, oauth_client_id/oauth_scope, даты), футер actions: OAuth-кнопка (вариант зависит от наличия токена) + «Редактировать» + «Удалить. Ручная модалка → ui/Modal size=“lg”с единымinputClass; per-row OAuth-флоу guard через Set(по образцуdeletingIdsиз subagents).EmptyState`.

  • /settings/subagents: ручной <div>ResourceCard (title — slug name, бейджи is_builtin purple / !enabled danger, meta — toolsSummary через parseToolsWhitelist + «без делегирования»/«своя модель»/temp/max_iterations/timeout, в children — display_name + description, в «Детали» — system_prompt/output_contract превью line-clamp + «полностью в редакторе», mcp_whitelist font-mono, sort_order, даты), футер actions «Изменить»/«Удалить» (per-row deletingIds guard и async-onConfirm ConfirmModal сохранены). EmptyState.

  • Единый UX карточек: иконка слева → заголовок + бейджи → meta через · → children → expandable «Детали» → футер actions с Button variant="ghost" size="sm". Skeleton — RowListSkeleton, empty — EmptyState с actions, модалки — ui/Modal, форматирование дат — общий $lib/utils/formatDate (formatDateTime), единый inputClass для форм.

  • Проверки: npm run check — 0 ошибок (278 warnings — pre-existing a11y, как в эталоне #226); npm test — 524/524; make build-webui — OK.

  • Унификация карточек на /settings/{connections,database,models,providers}. Четыре страницы настроек были оформлены разнородно: разные заголовки (h1/h2, разные размеры/weights), разные карточки (фон bg-surface/bg-panel, padding p-4/p-5, отличающаяся структура), разные бейджи (rounded-full/rounded, text-white/text-badge-*-fg), разные кнопки (text-white/text-accent-fg), три независимые ручные реализации модалок. Готовые UI-компоненты (ui/Button.svelte, ui/Badge.svelte, ui/Modal.svelte) на этих страницах не использовались. С бэкенда приходили поля, не показанные на карточках (LLMProviderConfig.extra_headers/extra_body/enabled/kind/created_at/updated_at/test_time, Model.extra_headers/extra_body/created_at/updated_at, Connection.created_at/updated_at).

  • Новые UI-примитивы: web/src/lib/components/ui/ResourceCard.svelte (карточка ресурса со слотами icon/badges/meta/children/details/actions, expandable-секцией «Детали» через нативный <details>, акцентная подсветка для активных ресурсов) и web/src/lib/components/ui/SettingsPageHeader.svelte (единый заголовок секции настроек: h2 text-xl font-bold, слоты badge/actions). ResourceCard использует untrack для initial-open пропса и $derived для определения наличия details-сниппета.

  • Унификация: все 4 страницы переведены на SettingsPageHeader + ResourceCard + готовые Button/Badge/Modal. Устранены сырые bg-black/50/bg-black/60, text-white, расходящиеся padding’и. Модалки создания/редактирования (connections, models, providers) переведены на ui/Modal (единый overlay bg-overlay, rounded-xl, фокус-ловушка, Escape, restore body scroll) и единый класс input: bg-elevated border border-field rounded-lg px-3 py-2 focus:border-accent.

  • Данные на карточках: второстепенные поля (extra_headers, extra_body, created_at, updated_at, test_time, test_error, kind, supports_tool_calling) теперь отображаются в expandable «Детали» в формате <dl>-сетки; метаданные на meta-строке (provider, api_base, test_models мод., test_latency_ms, has_api_key, context_window tokens) — через ·-разделитель. has_api_key и категория провайдера (Local/RU/Gateway) — через Badge с семантическими variant.

  • /settings/models реорганизован: убраны табы-вкладки LLM/Embedding/Reranker (путали поток — выбор активных embedding/reranker был вынесен в отдельные секции внизу под <hr>, дублируя фильтр). Структура теперь линейна: Зона А «Активные модели»Card с grid из 3 компактных карточек (LLM по умолчанию / Embedding / Reranker), каждая с select-выбором и кнопками «Применить»/«Сохранить»/«Тест»; результат теста реранкера (RerankTestResult) встроен прямо в карточку. Зона Б «Все модели»Card с компактными фильтр-чипами (Все/LLM/Embedding/Reranker) над списком, каждая модель в ResourceCard (бейдж типа purple/cyan/pink по kind, «По умолчанию», «Выкл»). Зона В «Синхронизация из провайдеров»Card с grid-кнопками. Выбор LLM по умолчанию из карточки «Активные» корректно вызывает api.models.setDefault (без отдельного API, как и раньше).

  • Проверки: npm run check — 0 ошибок (warnings не в моих файлах); npm test — 524/524; make build-webui — OK.

  • Post-review (ocr review, ): исправлены замечания code review:

  • ui/Modal.svelte: добавлен mousedown-vs-click guard на backdrop (регрессия от перехода на ui/Modal — раньше перетаскивание текста из input на backdrop закрывало модалку и теряло ввод; теперь закрытие только если mousedown и click были на самом backdrop’е). Правка в общем компоненте — все потребители ui/Modal получают fix.

  • models/+page.svelte: defaultLlm теперь требует m.enabled (раньше в карточке «Активные» могла показаться выключенная модель); applyDefaultLlm проверяет target.enabled перед записью; selectedDefaultLlmId сбрасывается в load() только если пользователь ещё не выбирал или выбор устарел (иначе per-row «По умолчанию» молча стирает staged-выбор); syncFromProvider документирует поведение «Все → LLM» (api.syncModels не принимает ‘all’); введены отдельные типы FilterKind/ModelKind + guard asModelKind (приводит строковый m.kind из БД к объединению).

  • connections/+page.svelte: разделены pageError/modalError (баннер страницы и баннер модалки больше не дублировали/протекали друг в друга); добавлен closeConnModal() — единая точка закрытия, очищающая modal-scoped state; token-field получил autocomplete="new-password" + spellcheck="false" + примечание о том, что токен не сохраняется в состоянии формы.

  • Общее: форматирование дат переведено на общий $lib/utils/formatDate (formatDateTime, locale через navigator.language) вместо локальных formatDate с захардкоженным 'ru-RU'; единый inputClass вынесен в $lib/utils/form.ts (INPUT_BASE без border + INPUT_CLASS с border-field, чтобы поля с ошибкой могли собирать класс без конфликта border-color’ов).

  • Провайдеры без нативного tool calling возвращали 400. Some OpenAI-compatible backends (LiteLLM proxy routes, локальные модели без function calling, некоторые gateway-модели) reject the tools parameter and tool/tool_calls-сообщения с HTTP 400; до этого фикса tools отправлялись безусловно (internal/providers/openai_compat.go:442) и такой провайдер нельзя было использовать для tool-driven agent loops. Реализован emergency fallback по образу strip_native_tool_messages из zeroclaw (compatible.rs:1645-1707):

  • Флаг supports_tool_calling per-provider в БД (миграция 096, колонка BOOLEAN NOT NULL DEFAULT 1 в llm_providers, парные SQLite/PG миграции). UI-тoggle в advanced-секции формы провайдера (ProviderFormModal.svelte). Create handler defaults по kind: только llmtrue, embedding/rerankerfalse; unknown kinds теперь валидируются по allowlist (llm/embedding/reranker) и возвращают 400 до crypto/DB.

  • Dynamic-detect + retry. При 400 + паттерне tool-calling-rejection провайдер атомарно (atomic.Bool + CompareAndSwap) сбрасывает флаг in-memory и помечает ошибку ShouldRetry=true (Kind="tool_calling_not_supported"). Существующий retry-цикл (standardRetryStream/persistentRetryStream) повторяет → buildParams теперь strip-ит tool-сообщения и не отсылает params.Tools → успех. Состояние persistируется до перезапуска процесса; пользователь может закрепить через UI-toggle.

  • stripToolMessages (internal/providers/tool_strip.go, новый): assistant+tool_calls→plain (tool-only с пустым content → текстовый stub (assistant called: name(args)), чтобы downstream-модель не получила необъяснимый tool result); tooluser с XML-envelope <tool_result name="…" id="…">content</tool_result> (ошибки → <tool_error>); атрибуты и body экранированы от injection ("/</</tag>); consecutive same-role склеиваются через \n\n (Media deep-copied + appended, CacheLevel = max).

  • IsToolCallingNotSupportedError (error_classify.go): строго StatusCode==400 (transport-ошибки с StatusCode==0 НЕ детектятся — иначе transient-сбой с “tools” в URL навсегда отключил бы провайдер); snake_case-токены (tools_not_supported) матчатся только против pe.Code, sentence-form — против pe.Type/body. Классификатор MUST вызываться на wrapped *ProviderError (после wrapOpenAIError), не на raw openai-go error — это задокументировано и покрыто тестом TestIsToolCallingNotSupportedError_TransportErrorStatusCodeZeroNotDetected.

  • ProviderConfig.SupportsToolCalling*bool (factory.go): nil = “unset” → конструкторский default true сохраняется (защищает literal-construction call-сайты: тесты, diagnostics, handler). DB-backed constructors (CreateFromLLMProviderConfig/CreateFromProviderAndModel) передают явное значение из строки; legacy Load (agent:%d:* settings) — nil.

  • UI toggle. ProviderFormModal.svelte: чекбокс «Поддержка tool calling» с описанием. Поле всегда отправляется (не условно) — toggle должен persistиться даже в false.

  • Известное ограничение (задокументировано): live *OpenAICompatProvider instance не hot-reload’ится после UpdateLLMProvider — UI-toggle persistируется в БД и подхватится при следующем build; auto-flip односторонний до restart. Hot-reload живого instance — отдельная задача.

  • Тесты: tool_strip_test.go (15 кейсов: envelope, escape-инъекция, stub, merge, CacheLevel-max, Media deep-copy), error_classify_test.go (StatusCode==0/429/500/code-only/body-only), tool_calling_fallback_test.go (CAS one-shot + concurrent), factory_test.go (nil-branch + anthropic ignore), store/supports_tool_calling_test.go (round-trip + default), handler/provider_supports_tool_calling_test.go (5 tri-state кейсов). anthropic.go намеренно не тронут (нативная поддержка tools).

  • Вводящий в заблуждение комментарий про rlimit в exec.go (A-006,; misdiagnosis). Аудит A-006 заявлял DoS-обход containment: якобы ulimit -t 60 в sh-префиксе ставит только soft limit, и агент-управляемая команда поднимает его обратно через <cmd>; ulimit -t unlimited. При реализации фикса обнаружено, что обход не воспроизводится: WrapCommandWithLimits генерирует ulimit -t N без флагов -S/-H, а POSIX-шеллы (bash, dash, darwin sh) при вызове без флагов ставят одновременно soft И hard (man bash: “If neither -H nor -S is specified, both the soft and hard limits are set”). Hard limit непривилегированный child не поднимет без CAP_SYS_RESOURCEulimit -t unlimited падает с EPERM. Эмпирически подтверждено на macOS (soft=hard=60; ulimit -t unlimited → EPERM); на Linux поведение идентично по POSIX. Предложенный в аудите SysProcAttr.Rlimit невозможен — такого поля в stdlib Go нет ни на linux, ни на darwin.

  • Реальные изменения. Исправлен вводящий в заблуждение комментарий в internal/agent/tools/exec.go:283-285 («на Linux лимиты установятся через SysProcAttr.Rlimit позже» — это неправда, поля нет в stdlib; лимиты применяются через ulimit-префикс). Обновлён комментарий в internal/security/rlimit_unix.go: документирует POSIX-семантику и почему форма без флагов — единственно корректная (-H на darwin → EINVAL; -S → обход), плюс известное ограничение (RLIMIT_AS/-v/MemoryMB на Darwin не поддерживается — mem-containment требует sandbox-exec).

  • Тесты. internal/security/rlimit_test.go: TestResourceLimits_CPUCannotBeRaised (regression-proof — доказывает, что атака ulimit -t unlimited; ulimit -Ht unlimited из A-006 не поднимает лимит; страхует от регресса, если префикс переключат на -S), TestWrapCommandWithLimits_NoSoftOnlyFlag (запрет -S-флага в префиксе).

  • Документировано. docs/audit-20260624.md: секция A-006 → FIXED (misdiagnosis) с подробным расследованием.

  • agent_max_concurrent игнорировался — хардкод 3 в main.go. Параметр виден в /settings/advanced, но cmd/taigaclaw/main.go всегда передавал литерал 3 в agent.NewAgentLoop, делая недостижимой ветку чтения из БД (loop.go:110-112, условие maxConcurrent <= 0). Семафор Loop.sem всегда создавался на 3 слота, ползунок в UI не давал эффекта. Фикс: main.go теперь читает settings.GetInt(ctx, s, "agent_max_concurrent", RegistryDefaultInt(...)) (default синхронизирован с реестром). Параметру добавлен Restart: true (семафор создаётся один раз при старте).

  • Неточные флаги Restart в /settings/advanced. Три параметра имели расходящиеся с реальным поведением маркеры «требует перезапуска»:

  • subagent_max_global_concurrent — кэшируется процессом через sync.Once навсегда (subagent.go:globalConcurrentLimit), но флага не имел → добавлен Restart: true.

  • subagent_max_result_chars — кэшируется в SessionActor при первом использовании (actor.go:subagentResultCharsInit), но флага не имел → добавлен Restart: true.

  • cleanup_interval_hours — перечитывается при каждом тике FactCleanupService.runLoop (значит, hot-apply работает), но имел Restart: true → флаг избыточен и удалён, описание обновлено.

Removed

  • dedup_cosine_threshold — мёртвый параметр дедупликации фактов. Настройка считывалась в persistFacts и тут же глушась _ = cosineMain (consolidator.go:337-338); двухуровневой логики «sim ≥ 0.88 → прямой merge, [0.80, 0.88) → LLM-merge», описанной в UI/комментариях, в коде никогда не было — resolveConflict (через LLM askMerge) вызывался для любого дубликата с sim ≥ dedup_llm_check_threshold (см. ADR 2026-05-22-fact-evidence-subject.md:88-96). Параметр удалён из ParamRegistry, getter getDedupCosineThreshold и константа defaultDedupCosineThreshold удалены, мёртвый код в persistFacts убран. dedup_llm_check_threshold (Default 0.80, диапазон расширен сверху до 0.99) — теперь единственный cosine-порог дедупликации; его описание и label обновлены. Семантически поведение дедупликации не изменилось (раньше и теперь LLM-merge применяется для sim ≥ llm_check).

  • SSH SSRF-байпас через -J / -W / -o ProxyJump= / ProxyCommand= (A-007, ). Medium-дефект security/SSRF из аудита 2026-06-24 (internal/security/network_host.go). Опции -J, -W, -o входили в sshOptsWithValue, поэтому extractSSHHost пропускал их значения как «не host» и не извлекал цель. При включённом NetworkProtocols.SSH jump-host (ssh -J 10.0.0.1 user@example.com), ProxyJump (ssh -o ProxyJump=169.254.169.254 user@example.com) и ProxyCommand (ssh -o ProxyCommand="nc 169.254.169.254 80" user@x) уходили в ssh без проверки IsHostPrivate — обход фильтра → доступ к метадате и приватным сетям через jump-host.

  • Фикс. extractSSHHost(args) заменён на extractSSHHosts(args) ([]hosts, hasProxyCommand): разбирает значение -J (список [user@]host[:port] через запятую), -W host:port, -o ProxyJump=/proxyjump= (case-insensitive) и прогоняет через IsHostPrivate наравне с основным таргетом. Опция -o ProxyCommand= (произвольная команда — nc/socat/…) блокируется fail-closed через новый HasSSHProxyCommand в ExecGuard.Check и SanitizeNetworkHosts — парсинг хоста из arbitrary command хрупок и выходит за рамки SSRF. Существующие валидные ssh-команды (-o StrictHostKeyChecking=no, -p, combined opts) не сломаны.

  • Тесты. network_host_test.go: извлечение jump/proxy-хостов (TestExtractNetworkHosts_SSHJumpHost/_JumpHostList/_JumpHostWithUserPort/_WOption/_ProxyJumpOption/_ProxyJumpAttached/_ProxyJumpCaseInsensitive/_ProxyJumpMulti), детекция ProxyCommand (TestHasSSHProxyCommand_Detected/_NotDetected), блокировка в guard (TestExecGuard_SSHProxyCommandBlocked/_SSHJumpHostToPrivateBlocked), регресс обычных опций (_SSHRegularOptsUnchanged).

  • TOCTOU / DNS-rebinding в exec/ssh-пути (A-008, ). Medium-дефект security/SSRF из аудита 2026-06-24 (internal/security/network_host.go, используется в guard.go/exec.go). IsHostPrivate резолвит host (resolution #1) и возвращает bool, затем ssh/exec-сабпроцесс резолвит тот же host повторно (resolution #2) и подключается. Между проверкой и dial — окно DNS-rebinding: evil.example → публичный IP при проверке → 169.254.169.254 при подключении. В отличие от HTTP-пути (checkDialAddr резолвит один раз и dial-ит IP-литерал), exec-путь этой защиты не имел.

  • Фикс. Введён инжектируемый Resolver-интерфейс (*net.Resolver ему удовлетворяет) на SSRFGuard (SetResolver для тестов, fallback net.DefaultResolver в проде) и метод ResolveHostForExec(ctx, host) — резолв ОДИН раз с fail-closed проверкой приватности и short-circuit для IP-литералов (без DNS). Новый ExecGuard.SanitizeNetworkHosts(cmd) переписывает каждый hostname в команде на пиннутый IP-литерал: ssh-таргет user@hostuser@<ip> + -o HostKeyAlias=<host> (сохраняет проверку ключа), -J/-W/-o ProxyJump= → IP в значении, scp/rsync user@host:pathuser@<ip>:path, telnet host port<ip> port. internal/agent/tools/exec.go вызывает sanitize после guard.Check — команда, уходящая в exec.CommandContext, пиннит уже отрезолвленный IP; повторного DNS нет, окно rebinding закрыто. Двойной резолв (Check + Sanitize) в одном процессе — за микросекунды, безвреден; авторитетен resolve из Sanitize.

  • Тесты. network_sanitize_test.go: rebinding-сценарий через mock resolver (TestSanitizeNetworkHosts_DNSRebindingAttackFails — hostname не протекает в вывод, IP пиннится; «переворот» резолвера в приватный IP не влияет на пинннутую команду), пиннинг ssh/scp/rsync/telnet/jump/ProxyJump (_HostnamePinned), блок приватного/неразрешимого (_PrivateResolvedBlocked/_UnresolvableBlocked), IP-литерал без DNS (_IPLiteralUnchanged), сохранение remote-команды (_RemoteCommandPreserved), ResolveHostForExec (multi-addr any-private блок, IP-литерал без resolver-вызова), shellQuoteToken.

  • Документировано. ADR docs/adr/2026-06-28-ssrf-exec-host-pinning.md; godoc на IsHostPrivate описывает известное ограничение TOCTOU и рекомендует ResolveHostForExec + SanitizeNetworkHosts. Известное ограничение: chained-команды (ssh h1 ... && ssh h2) обрабатываются по первому сетевому таргету (наследовано от грамматики разбора).

  • Неограниченный io.ReadAll ответа в Proxy → OOM (A-014, ). Medium-дефект из аудита 2026-06-24 (internal/connections/proxy.go). TestConnection и GetWSTicket читали весь ответ remote-сервера в память через io.ReadAll(resp.Body), и только потом обрезали до 512 байт. MaxBodySize ограничивает только входящий request-body, не исходящий ответ; client.Timeout=30s ограничивает время, но не объём. Злонамеренный/сломанный remote мог стримить гигабайты → OOM desktop-процесса при TestConnection к сломанному серверу.

  • Фикс. Введена константа maxTestBodyBytes = 64 * 1024; оба io.ReadAll(resp.Body) (TestConnection:225, GetWSTicket:254) обёрнуты в io.LimitReader(resp.Body, maxTestBodyBytes). 64 KiB с запасом покрывает любой легитимный status/ticket-payload и существующую обрезку сниппета до 512 байт.

  • Тесты. internal/connections/proxy_test.go (новый файл): TestProxy_TestConnection_BodyLimited (remote отдаёт 256 KiB → вызов завершается без OOM, сниппет ≤512), TestProxy_GetWSTicket_BodyLimited (non-OK ответ 256 KiB → body в error-message ограничен maxTestBodyBytes, без LimitReader было бы 256 KiB).

  • SSRF-guard на Proxy ставился только при provider != nil (A-009, ). Medium-дефект security/SSRF из аудита 2026-06-24 (cmd/taigaclaw/main.go). connProxy.SetSSRFGuard(agentLoop.SSRFGuard()) вызывался только внутри if provider != nil. При активной конфигурации remote-mode без LLM-провайдера (или agentLoop == nil) connProxy.ssrfGuard оставался nil, и admin-only POST /api/v1/connections/test (TestConnection) терял SSRF-защиту — в т.ч. доступ к метадате 169.254.169.254. Глобальный ssrfGuard строился безусловно (main.go:118), но в Proxy не попадал.

  • Фикс. connProxy.SetSSRFGuard(ssrfGuard) вынесен из if provider != nil и вызывается безусловно сразу после connections.NewProxy(connMgr) (main.go:152). Условный блок ниже переустанавливает тот же guard через agentLoop.SSRFGuard() — обе ветки сходятся на идентичном guard, логика сохранена.

  • Тесты. TestProxy_TestConnection_SSRFBlocked (при установленном guard TestConnection к 169.254.169.254 блокируется до HTTP-запроса с упоминанием “SSRF”), TestProxy_TestConnection_NoGuardReachesServer (контроль: без guard запрос к httptest-серверу доходит).

  • memory_document без лимита размера content → DoS / cost amplification (A-010, ). Medium-дефект DoS/cost из аудита 2026-06-24 (internal/agent/tools/memory_document.go). В отличие от соседнего MemoryRememberTool (memory.go:524, лимит 2000 символов), memory_document.Execute принимал content без ограничений. Далее idx.Index() через splitIntoChunks плодил неограниченное число чанков, Embed(ctx, texts) делал массовый платный embedding-вызов, цикл — N INSERT’ов. LLM (в т.ч. через prompt-injection из индексируемого документа) мог передать мегабайты.

  • Фикс. Введена константа maxDocumentBytes = 256 * 1024 и ранний reject в Execute перед idx.Index (по образцу memory.go/todo_write.go). TDescription дополнен строкой про лимит.

  • Тесты. TestMemoryDocumentTool_ContentTooLong (content = лимит+1 → осмысленная ошибка LLM, чанки не создаются), TestMemoryDocumentTool_ContentAtLimit (content ровно maxDocumentBytes принимается — защита от off-by-one >= вместо >).

  • Race двух параллельных Install — окно unlock/lock вокруг CheckNow (A-003, ). High-дефект integrity из аудита 2026-06-24 (internal/updater/service.go). В Install после взятия installMu лок временно отпускался (UnlockCheckNowLock), но публичный CheckNow сам делает TryLockUnlock и не держит лок во время сетевого fetch’а манифеста. В окно между отпусканием и возвратом второй параллельный POST /api/v1/updates/install проходил TryLock и начинал скачивание → оба доходили до applyUpdate → повреждённый бинарник/сломанный backup.

  • Фикс. Из CheckNow вынесен приватный checkNowLocked(ctx) (вся логика fetch/verify/state-update без installMu; caller обязан удерживать лок). CheckNow теперь: dev-чек → TryLockdefer UnlockcheckNowLocked. В Install окно Unlock/CheckNow/Lock заменено прямым вызовом checkNowLocked(ctx) — лок держится до конца Install без единого отпускания. StartBackgroundChecks использует публичный CheckNow как прежде.

  • Тест. TestInstall_Concurrent_SecondReturnsErrBusy: первый Install (через блокирующий RoundTripper) зависает на HTTP-запросе манифеста, удерживая installMu; второй Install обязан вернуть ErrBusy. На старом коде второй прошёл бы TryLock и не вернул ErrBusy.

  • RAG-изоляция во время reindex + recovery orphan reindex-jobs (A-004, A-005,, #193). Два High-дефекта подсистемы internal/embeddings/reindex.go из аудита 2026-06-24, выявляющих мусорные результаты RAG и stuck-job после crash.

  • A-004 — RAG не блокируется во время reindex. Во время фоновой переиндексации buildMemoryContext продолжал ходить векторным путём, хотя вектора в БД — смесь старых/искажённых/новых с рассинхроном размерностей (на PostgreSQL ALTER TABLE … TYPE vector(N) переписывает колонку целиком до пересчёта). Теперь Reindexer выставляет in-memory atomic.Bool флаг (InProgress()), который читается RAG-путём через интерфейс agent.ReindexProgress (duck-typed, без cyclic import). При InProgress()==true RAG деградирует до FTS-only fallback: пропускаются Embed(query), SearchFacts/SearchChunks (vector) и applyMMR; сохраняются SearchFactsFTS/SearchChunksFTS, RRFMerge, time-decay, importance-weight и rerank. Флаг сбрасывается в defer (даже при panic). Дополнительно: на PG EnsureVectorDimensions обёрнут в tx с SET LOCAL statement_timeout=0 и SET LOCAL lock_timeout='5min' (по образцу миграции 084) — раньше pool-timeout 300с мог оборвать ALTER на большой таблице, а lock_timeout теперь даёт явную ошибку вместо бесконечного ожидания чужого ACCESS EXCLUSIVE. Решение зафиксировано в ADR 2026-06-28-reindex-rag-isolation.md. Тесты: TestBuildMessages_RAG_FTSOnlyDuringReindex, TestBuildMessages_RAG_HybridWhenNotReindexing (контроль), TestReindexer_InProgressFlag.

  • A-005 — Stuck reindex-job после crash, нет recovery на старте. После crash процесса orphan reindex-job (status='running') оставался в БД навсегда: Start падал в ErrAlreadyRunning, Cancel в свежем процессе — no-op (cancel == nil). Добавлен Store.ResetStaleReindexJobs(ctx) (int, error) (SQLite + PostgreSQL + DualStore, по образцу MarkAllOrphanedSkillRuns) → UPDATE reindex_jobs SET status='failed', error='interrupted by restart from '||status||', processed='||processed WHERE status IN ('pending','running') (исходный status и processed дописаны в error для диагностики). Вызывается в cmd/taigaclaw/main.go перед NewReindexer. На PG обёрнут в IsUndefinedTableError для pre-migration-077 defense. Тесты: TestResetStaleReindexJobs_OrphanRecovery/_IdempotentNoActive/_PreservesTerminal в store, TestReindexer_OrphanRecoveryOnStart в embeddings.

Added

  • System prompt: batched tool_calls + TTFB для всех call-сайтов. Анализ JSONL-логов после v0.148.0 раскрыл главный bottleneck TaigaClaw vs opencode: LLM вызывает exec по одному за итерацию вместо батчинга. Пример из лога: один ReAct-ход = 27 LLM-запросов, каждый с tools=['exec'] (по одной команде за раз). При этом LLM ИНОГДА батчит (tools=['exec','exec']), значит модель умеет, но не делает этого систематически. Сравнение с opencode показало: в system prompt opencode есть явные инструкции про parallel/batched tool-calls в 6 из 8 промптов (anthropic.txt:83, default.txt:92, gpt.txt:6, kimi.txt:13). TaigaClaw говорил только “use tools to accomplish them all in one turn” — без упоминания parallel/batched/single-response.

  • P0 — System prompt batched tool_calls. В internal/agent/context.go:buildToolCallStyle добавлена явная инструкция: “Batch independent tool calls: when multiple tool calls do not depend on each other’s output, issue ALL of them as parallel tool_calls in a SINGLE response. Do NOT serialize independent calls across multiple turns: each extra turn costs the user ~15 seconds of latency.” С примером (git status + git diff → один ответ с двумя tool_calls) и оговоркой про последовательные вызовы при зависимостях. Тест TestBuildToolCallStyle_BatchedToolCallsInstruction пиннит наличие ключевых слов (parallel, single response, do not serialize). Ожидаемый эффект: p90 с 8 → 3-4 итераций = -60s на ход.

  • P1 — TTFB для всех call-сайтов. Перевод 12 call-сайтов с provider.Chat / ChatWithRetry на ChatStreamWithRetry для полноты TTFB-observability: heartbeat (2), summary, compress, plan (2), autocompact, consolidate (3), dream (3), kg_extract, rerank, skill_reflection. Особый случай: providers/oneshot.gocollectStream напрямую (без двойного retry, сохраняет per-attempt timeout 20s). Mock-провайдеры в тестах (4 шт.) обновлены на streaming-delegation паттерн.

  • Не сделано (обоснованно данными): continuation markers (0% match на русском агенте), PlanExecute (0 plan-запросов), Strategy Router (oneshot = 17 из ~200, не доминирует), кэш exec (после P0 — собрать данные о повторах).

  • TTFB для всех запросов + HTTP Transport pooling + per-guard client cache. После v0.147.0 собраны данные на проде: cache-hit 90.7% на react, но TTFB = 0 для всех запросов (агент работает в non-streaming режиме runChat). Latency-структура: tiny (<50 tok) = 6.5s fixed, растёт до 67s на huge. Без TTFB-данных нельзя точно сказать, сколько из 6.5s — наш connect overhead, сколько — LiteLLM queue/prefill. Этот релиз закрывает три root cause:

  • Часть A — TTFB для всех запросов через internal-streaming. AgentRunner.runChat теперь ходит к провайдеру через ChatStream под капотом: caller не стримит (дельты собираются в строку через новый providers.collectStream), но TTFB измеряется корректно. Новый providers.ChatStreamWithRetry — streaming-аналог ChatWithRetry с теми же retry-политиками (standard/persistent). collectStream имеет defensive-check на (nil, nil) от ChatStream, idle-timeout guard 120s. Mock-провайдеры в тестах обновлены на streaming-delegation. Стратегия react теперь даёт TTFB > 0 в llm_request_log.ttfb_ms.

  • Часть B — HTTP Transport tuning. security/ssrf.go:safeTransportFromGetter приведён к стандарту internal/connections/proxy.go: MaxIdleConnsPerHost: 100 (раньше Go default 2!), MaxIdleConns: 100, IdleConnTimeout: 90s, TLSHandshakeTimeout: 10s, dialer.Timeout/KeepAlive: 10s/30s, ExpectContinueTimeout: 1s, ForceAttemptHTTP2: true. SSRF-проверки (checkDialAddr) сохранены полностью. Покрывает оба пути: static (*SSRFGuard).safeTransport делегирует в safeTransportFromGetter.

  • Часть C — Кэш per-guard клиента. providers/ssrf.go:safeHTTPClientForGuard теперь кэширует cloud-spec клиентов по ключу = SHA-256(sort(AllowedCIDRs + BlockedCIDRs)). Bounded FIFO (32 записи), eviction с CloseIdleConnections(). Раньше cloud-spec + reqGuard ≠ nil (агент с ip_whitelist) создавал новый *http.Client+Transport на каждый Chat/ChatStream → нулевое переиспользование соединений. Guard getter остаётся dial-time ресолвером — SSRF-постура не меняется. Добавлен публичный SSRFGuard.BlockedCIDRs() для корректного ключа (включая blacklist — без этого два guard’а с одним whitelist но разными blacklist collided бы — SSRF bug по ocr review).

  • Тесты (15 новых): TestSafeTransportFromGetter_HasConnectionPooling, TestSafeTransportFromGetter_PreservesSSRFDialHook, TestSafeHTTPClientForGuard_CachedByIdenticalGuardContent, TestSafeHTTPClientForGuard_DifferentBlacklistMissesCache, TestSafeHTTPClientForGuard_LocalSpecBypassesCache, TestPerGuardClientCache_FIFOEviction, TestSafeHTTPClientForGuard_NilGetterPanics, TestSafeHTTPClientForGuard_NilGuardGetterBuildsUncachedFailClosed, TestCollectStream_AssemblesDoneEvent, TestCollectStream_SurfaceErrorEventAsGoError, TestCollectStream_NilChannelReturnsError, TestCollectStream_OpenErrorPropagated, TestCollectStream_IdleTimeoutAbortsHungStream, TestChatStreamWithRetry_PersistentRetriesOnError.

  • Наблюдаемость LLM: разметка strategy, TTFB-замер, slog cache-hit. Анализ производительности TaigaClaw vs opencode через общий шлюз LiteLLM (glm-5.2) показал: cache-hit уже 91.9% (affinity не проблема), summary почти не запускается (1 запуск за 14 дней), но median latency одного запроса = 9s, p90 = 36s, и поле llm_request_log.strategy пустое у 6802/6802 строк — то есть «куда уходит время» было невидимо. Включена и расширена существующая инфраструктура llmlog:

  • Разметка strategy во всех call-сайтах ChatRequest — новое поле providers.ChatRequest.Strategy (предпочитается над llmlog.ContextWithStrategy, обратная совместимость сохранена). Теги: react, react_stream, summary, plan, oneshot, heartbeat, dream, consolidate, autocompact, compress, rerank, kg_extract. Механизм ContextWithStrategy для spawn/subagent/skill_reflection остаётся. Теперь SELECT strategy, avg(duration_ms) FROM llm_request_log GROUP BY strategy режет latency по источнику.

  • TTFB (time-to-first-byte) для streaming-ответов — новое поле LLMResponse.TTFB + колонка llm_request_log.ttfb_ms (миграция 095, SQLite + PostgreSQL). Замер от time.Now() перед NewStreaming (SDK lazy — запрос уходит на первом stream.Next()) до первого наблюдаемого события. Даёт ответ на главный вопрос: из медианных 9s — сколько до первого токена (connect+queue), сколько генерация. TTFB сообщается и на error-ветках (idle-timeout, transport error): если чанк не пришёл, сообщается полная продолжительность ожидания — самый диагностический сигнал для «stream hung».

  • slog cache-hit строка в LogRequest — всегда эмитится на INFO (не только при успешном response с usage), чтобы mid-stream ошибки и responses без usage не теряли лог. Поля: agent_id, provider, model, strategy, duration_ms, ttfb_ms, status + (при наличии usage) prompt_tokens, completion_tokens, cached_tokens, cache_creation_tokens, cache_hit_pct. Cache-hit% = (cached + cache_creation) / prompt — обе формы prompt-cache benefit.

  • Transport TTFB через StreamEvent — новое поле StreamEvent.TTFB, позволяет нести TTFB на терминальных событиях (done и error), даже когда Response не собран (idle-timeout, mid-stream transport error). LoggingProvider синтезирует минимальный LLMResponse{TTFB} в error-case, чтобы колонка ttfb_ms заполнялась.

  • Тесты: TestOpenAICompatProvider_ChatStream_MeasuresTTFB, TestOpenAICompatProvider_Chat_NoTTFB, TestOpenAICompatProvider_ChatStream_TTFBOnError, TestLogger_TTFBPersisted, TestLogger_TTFBOmittedWhenZero, TestLogger_SlogEmittedOnErrorPath, TestLogger_SlogEmittedOnSuccessWithCachePct, TestResolveStrategy_RequestFieldWins.

  • Не сделано (обоснованно данными): prompt_cache_key/x-session-affinity (cache-hit уже 91.9%), async-summary (запускается 1 раз за 14 дней), Anthropic beta-заголовки (путь через OpenAI-compat).

  • Скелетоны на secondary-страницах — 4 новых компонента + применение на ~40 страницах. Page-load спиннеры (animate-spin) заменены на семантические скелетоны, отражающие форму реального контента. Эталонный паттерн — /agents/+page.svelte (использовал AgentCardSkeleton ещё с v0.144.0).

  • 4 новых компонента (web/src/lib/components/ui/):

  • RowListSkeleton.svelte — список карточек со shimmer, props rows/class. Списки cron, heartbeat, webhooks, members, permanent-memory, mcp, subagents, skills, providers, models, connections, dynamic-memory/docs, skills/improvements, log-settings.

  • TableSkeleton.svelte — таблица (header + N строк × M колонок), props rows/cols/class. audit, llm-log, users, skills/runs.

  • LineFormSkeleton.svelte — форма (label+input строки), props fields/class. soul, strategy, profile, users/[id], edit/create-формы (cron, heartbeat, permanent-memory, skills, subagents), database, advanced. fields адаптирован: 3 для create-форм, 5 для edit-форм.

  • DashboardSkeleton.svelte — composite (KPI-grid + секции), props cards/class. /settings, /settings/metrics (cards=4), /settings/system (cards=5).

  • Доступность: все 4 компонента имеют aria-hidden="true" (скелетоны decorative, скринридеры их игнорируют). Базовый Skeleton.svelte уже имел aria-hidden с v0.144.0.

  • Применено на ~40 страницах через делегирование 2 параллельным агентам (row-list отдельно от table/form/dashboard).

  • Не тронуты 4 особые страницы: kg (граф-визуализация), chat (WS-сессия), permissions (матрица), dynamic-memory (composite с lazy-вкладками) — оставлены на onMount.

Changed

  • Мобильный dropdown для action-кнопок ChatHeader. На мобильном виде в шапке чата не помещались 4 action-кнопки (Новая тема, Данные, Избранное, Задачи) вместе с hamburger, аватаром агента, моделью и UserMenu. На < md все 4 кнопки теперь свёрнуты в один overflow-dropdown с kebab-триггером (⋮); на md+ — прежний вид с подписями. Полная клавиатурная навигация WAI-ARIA: role="menu"/role="menuitem", стрелки Up/Down + Home/End, Escape закрывает с возвратом фокуса на триггер, автофокус на первом пункте при открытии, :focus-visible-кольцо. Иконки вынесены в {#snippet} (5 шт. включая kebab) для устранения дублирования SVG между десктоп- и мобильным вариантами. Стили пунктов — в новый класс .ch-menu-item (рядом с .ch-action-btn). handleEscape получил guard «только если хотя бы один dropdown открыт», чтобы не мешать другим оверлеям. max-w-[calc(100vw-1.5rem)] на меню — защита от переполнения на 320px-экранах (паритет с model-dropdown). 0 ошибок svelte-check, 524 теста проходят.
  • Чистота дизайн-системы — 6 сырых Tailwind-цветов → токены (4 файла). Нарушения правила из design-tokens.md («никаких сырых Tailwind-цветов»): bg-green-500/bg-green-600/bg-green-700bg-success; bg-green-100 text-green-700bg-success-bg text-success-text; bg-red-100 text-red-700bg-danger-bg text-error-text; bg-gray-300 text-gray-700bg-dimmed text-label. Затронуты: ConnectionIndicator, approvals, models, connections.
  • Чистка 77 hex-фолбэков var(--c-*, #hex)var(--c-*) (3 больших компонента). Мёртвый код — :root всегда задаёт CSS-переменные, фолбэк никогда не срабатывал. Более того, hex-значения в фолбэках были устаревшей палитрой (#1a1a2e, #2a2a3e, не совпадающей с актуальной --c-panel: #111827). Чистка через perl в WorkspaceMDViewer.svelte (39 мест), FavoritesPanel.svelte (33), MarkdownRenderer.svelte (5). После чистки: 0 hex-фолбэков по всему проекту.

Fixed

  • Имя автора над баблом сообщения + единый левый край 52px в чате. Регрессия c260c87: имя автора (display: inline) помещалось в один flex-контейнер с аватаром (.msg-avatar-wrap, flex-shrink: 0), из-за чего ширина левой колонки зависела от длины имени — левый край облачка сообщения уплывал вправо на разную величину, сообщения стояли неровно, а длинные имена сужали бабл. Реструктуризация в Slack-стиле:

  • Две колонки: аварат — фиксированная колонка слева (40px, flex-shrink: 0); контент (flex-1 min-w-0) — справа. Левый край бабла константен (52px = 40px + gap 12px) для всех сообщений, независимо от имени.

  • Имя над баблом: <span class="msg-author-name"> перенесён из обёртки аватара в начало контент-колонки, display: block; text-overflow: ellipsis — длинные имена обрезаются многоточием, не ломая раскладку.

  • Общие классы в app.css: .msg-row/.msg-content/.msg-author-name вынесены из scoped <style> ChatMessage.svelte в глобальный CSS (рядом с .msg-action-btn), чтобы переиспользовать в StreamingMessage и ChatThinking.

  • StreamingMessage и ChatThinking: имя агента над баблом по той же схеме — убирает вертикальный «прыжок» в момент завершения стрима (у завершённого сообщения раньше имя появлялось, у стримящегося — нет).

  • Системное сообщение (cron/heartbeat): иконка канала w-8 h-8w-10 h-10 (40px), внутренний SVG w-4 h-4w-5 h-5 — бабл стартует на тех же 52px, в одну вертикаль с остальными.

  • Унификация левого края 52px: тул-карточки и разделители тем (MessageList.svelte, ChatToolPanel.svelte) — pl-11 (44px) → pl-[52px], рассинхрон 8px с баблами устранён.

  • Мобильный media-query flex-direction: column убран намеренно: row-layout (аватар слева) консистентен с десктопом и соответствует стандарту мессенджеров (Slack/Discord/WhatsApp); на 375px контент занимает 323px.

  • Чистовая косметика чата — закрытие аудита (9 пунктов).

  • Унификация typing-dots: ChatToolPanel.svelte (2 места — info/purple) и ToolIterationCard.svelte заменяли свои 3 animate-pulse спана на единый <TypingDots>. Консистентность с ChatThinking/StreamingMessage.

  • Хардкод-цвета мимо токенов: ChatToolPanel.svelte:175 text-gray-400text-hint; ChatAskUser.svelte:16 hover:border-blue-500hover:border-accent.

  • Эмодзи каналов (ChatMessage.svelte:41): channelIcon() возвращал ⏰/💓 строками — теперь channelIconPath() отдаёт SVG-path (Heroicons clock/heart), в разметке inline <svg>.

  • Имя автора на десктопе (ChatMessage.svelte): .msg-author-name был display: none на десктопе (только в title) — теперь виден всегда (text-hint text-xs), мобильный медиа-запрос убран.

  • Эмодзи типов файлов (ChatComposer.svelte:160): fileIcon() возвращал 🖼️🎬🎵📕📦📄. Удалён, заменён на новый компонент FileIcon.svelte (mime → SVG, Heroicons, data-driven table).

  • Donut на странице агента (agents/[id]/+page.svelte:409): таблица by_model была primary-визуалом — теперь <Donut> (топ-6, легенда с процентами, mobile-first сетка), детальная таблица в <details> как сворачиваемый fallback.

  • 🐾→<Logo> в login/+page.svelte и MessageList.svelte (empty-state чата) — паритет с layout/onboarding/sidebar.

Added

  • FileIcon.svelte (web/src/lib/components/) — переиспользуемый компонент иконки файла по MIME-типу (image/video/audio/pdf/archive/generic), Heroicons outline, data-driven ICON_PREFIXES table.
  • Суммарное время группы итераций в ToolIterationCard.svelte$derived через min/max started_at/completed_at, fallback на сумму took. Плашка · {groupDuration} в шапке рядом со счётчиком (N).
  • utils/parseDuration.ts + 7 unit-тестов — parseGoDurationMs() парсит вывод Go time.Duration.String() (включая multi-unit “1m30s”, “2m0.5s”, “1h0m0.5s”) в ms. Используется в groupDuration fallback. Решает баг: исходный regex ^([\d.]+)(ms|s|m)$ не парсил multi-unit Go-формат.

Changed

  • Визуализация метрик на /settings/metrics. Страница переписана: вместо 6 plain-text key-value секций теперь живые графики, переиспользующие charts-кит из v0.145.0. История сэмплов (30 точек при auto-refresh 5с ≈ 2.5 мин) для спарклайнов — дельты между сэмплами с NaN guard и console.warn при non-finite.
  • Топ-KPI со спарклайнами: LLM-запросы, HTTP-запросы, активные сессии, вызовы инструментов — через KpiCard (trend-индикатор ▲/▼).
  • LLM секция: Donut «структура токенов» (7 сегментов: in/out/cached/cache_creation/system/rag/history) + Bars «запросы/ошибки». Сумма процентов всегда 100% через tokenTotal из отфильтрованных сегментов.
  • Tools & Agent: latency через formatDurationMs (консистентно с KG), спарклайн тренда задержки, Bars calls/errors, блок итераций с лимитом.
  • KG per-agent: Bars entities с caption relations + summary-строка (экстракций/дедупликации/ср. время).
  • Memory per-agent: Bars active_facts + Donut by_category с селектором агента (effectiveCategoryAgentId fallback через $derived).
  • Mobile-first: metrics-chart-grid адаптив (donut centered + легенда ниже на <768px), grid-cols-1 sm:grid-cols-2 lg:grid-cols-*.
  • Рефакторинг charts-кита: выделен charts/tone.ts с единым типом Tone + toneVar lookup-table. Устраняет дублирование tone-union в Bars/Donut/Sparkline/KpiCard (раньше в каждом свой) и хрупкую интерполяцию строк var(--c-${tone}) — теперь все компоненты и consumer-страницы используют общий lookup. frost теперь валиден везде (раньше только в Donut).
  • Empty-states для KG/Memory: секции всегда показывают header + Bars/«Нет данных», а не скрываются полностью при пустых данных.

Added

  • Dashboard Phase 4 + Фаза 5 чат + мобильная адаптация. Комплексное обновление WebUI: живой дашборд с графиками вместо plain-text карточек, разблокировка смены модели на мобильном, единый typing-индикатор, tap-targets, и косметика мобильности (модалки, скролл-хинты, overlay).
  • Charts-кит (web/src/lib/components/charts/, новые): три pure-SVG компонента без зависимостей, mobile-first (viewBox + preserveAspectRatio), +15 unit-тестов на чистую математику.
  • Sparkline.svelte — мини line-chart с area-fill градиентом, тоны через CSS-переменные. Функции buildSparklinePoints/buildSparklineAreaPath выделены в sparkline.ts для тестирования.
  • Donut.svelte — donut chart через <circle> + stroke-dasharray. Функции normalizeSegments/segmentDash в donut.ts. Поддержка empty-state (пунктирный трек), accessibility через role="img" + сгенерированный aria-label.
  • Bars.svelte — горизонтальные бары с тонами и captions, адаптивная сетка для мобильных.
  • Dashboard rewrite (dashboard/+page.svelte + новый KpiCard.svelte):
  • KPI-карточки со спарклайнами (только админ — api.metrics() закрыт под RequireGlobalAdmin): LLM-запросы/ошибки, токены in/out/cached, вызовы инструментов. Poll каждые 45с, история 20 сэмплов (~15 мин), trend-индикатор (▲/▼ в KpiCard).
  • Token usage donut по api.memory.getUsage(agentId).by_model — топ-6 моделей, легенда с процентами (сумма всегда 100% благодаря visibleTotal), строчка «+ ещё N» для скрытых, селектор агента с bind:value и sr-only label.
  • Provider health bars через Bars.svelte — healthy показывают models_count, unhealthy — 0 с красным тоном и caption ошибки.
  • Mobile-first везде: grid-cols-1 md:grid-cols-*, donut centered + легенда ниже на мобильных.
  • ocr review ДО коммита (3 итерации): 10 валидных замечаний исправлены — bogus casts (as 'accent'), NaN guard в pushSample, $effect для сброса usageAgentId при удалении агента, accessibility (sr-only label, aria-label на legend, bind:value), donut total из visibleTotal (проценты в 100%), unhealthy provider видимый через tone+caption, parseInt guard, magic number POLL_INTERVAL_MS. Security-замечание (agent-scoped auth для memory.getUsage) — false positive: бекенд уже enforce через RequireAgentRole("agent_user") на всём route /agents/{agentID}.

Fixed

  • Блокер: сменить модель LLM на мобильном было невозможно (ChatHeader.svelte:169, hidden md:block). Теперь compact icon+chevron виден всегда, имя модели скрыто на <768px, dropdown прижимается вправо (right-0 md:left-0) с max-w-[calc(100vw-1.5rem)] против клиппинга.
  • Консистентность аватаров в чате: StreamingMessage.svelte — 32px → 40px (раньше скачок при завершении стрима, т.к. ChatMessage/ChatThinking уже были 40px).
  • Хардкод цветов мимо токенов (ChatComposer.svelte): bg-red-600/red-700 (кнопка Стоп) → bg-danger/bg-danger-hover/text-danger-fg; bg-emerald-500 (точка активного навыка) → bg-success.
  • Единый <TypingDots> вместо 3 разных индикаторов: ChatThinking (3 animate-bounce), StreamingMessage (каретка w-2 h-4 bg-hint animate-pulse) — теперь используют общий компонент TypingDots (consistency + prefers-reduced-motion).
  • Tap-target .msg-action-btn (app.css): 28×28 → min-w/min-h:40px (Apple HIG / Material 44px recommendation).
  • Мобильная адаптация (touch-устройства):
  • ConfirmModal + AvatarCrop + CreateTokenModal — backdrop px-3 sm:px-0 (контент больше не flush с краёв на 375px). ConfirmModal используется в 22 местах — высокий ROI одной правки.
  • 6 модалок max-h-[90vh]max-h-[90dvh] — нижние поля больше не уходят под мобильную клавиатуру.
  • Скролл-хинт для широких таблиц: утилитный класс .scroll-x-hint в app.css (fade через background-attachment: local/scroll), применён к llm-log (8 колонок).
  • Thinking-дропдаун клиппинг (ChatComposer.svelte) — right-0 sm:left-0 + max-w-[calc(100vw-1.5rem)].
  • Hover-overlay аватара (AvatarSection, agents/[id]/+page) — opacity-100 md:opacity-0 md:group-hover:opacity-100: на тач виден всегда (аффорданс), на десктопе по hover.

Changed

  • /agents/{id}/+page.svelte:385grid-cols-3 без брейкпоинта → grid-cols-1 sm:grid-cols-3 (раньше 3 колонки сжимались до ~103px на iPhone SE).

  • ocr review по Релизу A — 7 валидных замечаний (v0.144.2):

  • Logo.svelte — оптимизация загрузки. srcset теперь прогрессивный (favicon-32x32.png 1x → android-chrome-192x192.png 2x → android-chrome-512x512.png 3x) вместо грузящего 192px для рендера 28px. Добавлены decoding="async", loading="eager", aspect-ratio: 1/1 (превент CLS), draggable="false" (кросс-браузерная замена non-standard -webkit-user-drag). border-radius: 25% — точный паритет со старым SVG-логотипом (rx=8 на 32-unit viewBox).

  • AppSidebar.svelte — нормализация версии и defensive truncate. versionLabel теперь .trim().replace(/^\s*v\s*/i, '').slice(0, 32) — защита от vV1.2.3 / ведущих пробелов / спуфинга. latestSafe для update-pill — та же нормализация + обрезка до 40 символов (вернулась защита, удалённая в v0.144.1).

  • AppSidebar.svelte — anti-CLS резерв. .sidebar-bottom получил min-height: 120px чтобы theme-toggle не прыгал при асинхронной загрузке health и globalUpdates (с учётом update-pill).

  • ChatComposer.svelte — убран дублирующий title. Рядом с aria-label="Убрать вложение {name}" title="Убрать" был избыточен (двойное объявление скринридером на некоторых платформах).

  • parseError теперь показывает причину ошибки (web/src/lib/api/client.ts). До фикса (баг с v0.7.0) при JSON-ответе вида {"error":"manifest_unavailable","message":"Network error: ..."} фронт брал только data.error — пользователь видел голый код без объяснения. Теперь приоритет data.message → data.error → statusText. Затрагивает все ошибки API: обновления, сеть, авторизацию.

  • Logo.svelte использует настоящий логотип TaigaClaw (синий след лапы) вместо придуманного SVG. Заменён inline-SVG «когти на градиенте» на PNG через <img> с srcset для Retina (192px → 512x). Изображение одинаково в обеих темах. Убраны props mono и случайный gid (не нужны для растра).

  • Удалён мусорный web/src/lib/assets/favicon.svg — это был оранжевый логотип Svelte из SvelteKit-шаблона (<title>svelte-logo</title>, цвет #ff3e00), забытый при создании проекта (коммит 40cbcbe). Файл нигде не использовался (app.html ссылается на PNG/ICO, импортов нет), но лежал в репо как артефакт шаблона.

  • Мобильные мини-фиксы (touch-устройства):

  • Кнопка удаления вложения в чате (ChatComposer.svelte) — была голым текстом ~14px, теперь кнопка min-w/min-h:24px с hover-фоном (danger-bg). Tap-target соответствует.

  • Кнопка «Переотправить» в сообщении пользователя (ChatMessage.svelte:139) — была opacity-0 group-hover:opacity-100 (невидима на тач). Теперь opacity-100 md:opacity-0 md:group-hover:opacity-100 — всегда видна на мобильных.

  • Timestamp ответа агента (ChatMessage.svelte:256) — тот же фикс: всегда видим на <768px.

  • grid-cols-3 без мобильного брейкпоинта на странице агента (agents/[id]/+page.svelte:385) → grid-cols-1 sm:grid-cols-3. Раньше 3 колонки сжимались до ~103px на 375px; теперь стек на мобильном.

Changed

  • Sidebar — refinement нижней панели (AppSidebar.svelte):
  • Убран 🔔-бейдж с пункта «Администрирование» (семантически мимо — это системный статус, не пункт навигации).
  • Низ пересобран с центровкой: статус сервера (health dot + uptime), версия приложения (v{health.version}, mono, text-faint), и — при наличии — update-pill (pine-bg, кликабельный → /settings/system, «↻ Доступна {version}», min-height 32px для tap-target). Все элементы justify-content: center.
  • Collapsed-режим (56px): показывается только health-dot по центру, версия/pill скрыты.
  • Update-pill виден только админу (polling globalUpdates стартует при isAdmin()).

Added

  • Эволюционное улучшение дизайна WebUI — Фазы 0, 2, 3, 1. Брендовая идентичность «тайга» и системные UX-улучшения поверх существующих дизайн-токенов, без ломающих изменений. Подготовлен детальный аудит (чат-интерфейс + доступные API-данные для дашборда) и визуальные превью перед реализацией.

  • Фаза 0 — Фундамент (токены и утилиты):

  • Таёжная палитра (web/src/app.css, docs/design-tokens.md): брендовые акценты --c-pine (хвоя), --c-amber (янтарь), --c-frost (мороз) с вариантами bg/text/border в :root, [data-theme=light] и @theme. Декоративные акценты, не пересекаются с семантическими статусами (success/warning/info).

  • Брендовый градиент --c-brand-gradient (accent → pine), focus-ring токены --c-ring/--c-ring-offset (единый стиль фокуса вместо жёсткого --c-link), свечения --c-shadow-glow-accent/--c-shadow-glow-pine, shimmer-токен --c-shimmer для скелетонов.

  • Компонент TypingDots.svelte (ui/) — единый typing-индикатор (3 точки) с prefers-reduced-motion, размерами sm/md и тоновым токеном; экспортируется из ui/index.ts.

  • Компонент Skeleton.svelte (ui/) — примитив скелетона с shimmer-анимацией и адаптацией под обе темы.

  • Utils format.ts + 15 тестов: formatDurationMs, formatTimeRange, formatTokens, formatRelative — выносят дублированное форматирование времени/токенов. ToolIterationCard рефакторнут на общую formatTimeRange (удалена локальная копия).

  • Фаза 1 — Бренд:

  • Компонент Logo.svelte — inline SVG «коготь» с брендовым градиентом (accent→pine) и mono-вариантом; подчиняется CSS-переменным и темизации. Заменяет эмодзи 🐾 в экране загрузки (+layout.svelte) и онбординге.

  • Фаза 2 — Sidebar и навигация (AppSidebar.svelte):

  • Группировка навигации (Основное / Работа / Система) с подписями-разделителями.

  • Хвойная полоска-индикатор активного пункта (::before + shadow-glow-pine) вместо плоского фона.

  • Живые бейджи: счётчик агентов на пункте «Агенты» (один api.agents.list() на mount).

  • Health-индикатор внизу сайдбара (status + uptime, api.health() с refresh 60с).

  • Лого в шапке сайдбара через Logo.svelte (вместо favicon-32x32.png).

  • Убраны хардкод-фолбэки #1a1a2e/#2a2a3e/#999 (8 мест) в AppSidebar, ChatHeader, PageHeader — только токены.

  • Фаза 3 — AgentCard, скелетоны, пустые состояния:

  • AgentCard.svelte: hover-подъём translateY(-2px) + shadow-glow-accent, чипы метрик из данных объекта Agent (контекст % с pine/amber-индикатором, кол-во провайдеров/моделей), относительное время обновления. Без лишних API-вызовов (N+0 запросов).

  • AgentCardSkeleton.svelte + EmptyState.svelte — переиспользуемые компоненты. Скелетоны заменяют animate-spin на страницах /agents и /dashboard.

  • Уникальные empty states с CTA («+ Создать агента», брендовый градиент) вместо одинаковой иконки-монитора.

  • Проверки: npm run check — 0 errors (309 pre-existing a11y warnings); npm test — 502/502 passed (33 файла, +15 новых); npm run lint — 0 ошибок в затронутых файлах; npm run build — Tailwind v4 корректно генерирует классы bg-pine/shadow-glow-*/brand-gradient.

  • Named subagents, Этап 4 (frontend) + per-profile can_spawn (ADR 2026-06-24-named-subagents). Этап 4 — WebUI для библиотеки профилей и подключений; can_spawn — новый runtime-параметр профиля (Forward-comcompat пункт 4). Этапы 0–3 (safety + Store + API + agent-интеграция) реализованы ранее (#217).

  • Этап 4 — Frontend:

  • Библиотека профилей (/settings/subagents): список карточек (slug, display_name, описание, toolset-summary, бейджи builtin/disabled/без-делегирования/своя-модель), CRUD-флоу через +page.svelte/new/+page.svelte/[id]/+page.svelte. Удаление через ConfirmModal с in-flight-guard (Set ID, не boolean — параллельные delete на разных строках не блокируют друг друга) и keep-open-on-error (модалка закрывается только при успехе, при ошибке пользователь видит баннер в контексте).

  • Эдитор профилей (SubagentProfileEditor.svelte): whitelist multi-select из каталога tools с wildcard-опцией ["*"], бейджи interactive/read-only для каждого tool, live-валидация (slug regex, длина полей, диапазоны temperature/maxIter/timeout), warning для reasoning-моделей (opus-4/o1/o3/gpt-5 — temperature игнорируется), prompt-injection warning для LLM-полей, защита builtin-ядра (slug/system_prompt/tools — readonly/disabled при is_builtin=1). untrack() для одноразового snapshot’а initialData в форму (устраняет 12 state_referenced_locally warnings Svelte 5 — parent перемонтирует через {#key profile.id}).

  • Подключение к агенту (/agents/[id]/subagents): toggle включения подагентов + numeric-поля лимитов (MaxConcurrent/MaxSpawnDepth/MaxChildren/Timeout/MaxIterations) с tooltip’ами, блок «Профили специалистов» с attach/detach/toggle-enabled, optimistic-update с rollback при ошибке.

  • Sidebar-пункт «Подагенты» в SettingsSidebar.

  • Бейдж [profile_name] в ChatToolPanel — панель активных подагентов показывает slug профиля для named spawns (поле profile_name в ActiveSubagent chat-store, проброс из WS-события subagent_spawned.profile).

  • TS API-клиент: типы SubagentProfile/AgentSubagentConnection/SubagentRun/ToolCatalogEntry; группа api.subagentProfiles.* (list/get/create/update/delete/getCatalog) + agent-scoped (listForAgent/listRuns/attach/detach/setEnabled).

  • Utils (subagentProfile.ts): isValidSlug, parseToolsWhitelist/serializeToolsWhitelist/isWildcardWhitelist, modelOmitsTemperature (mirror backend isReasoningModel), константы лимитов (mirror backend validateProfileFields) с TODO вынести на сервер для устранения drift.

  • Per-profile can_spawn (Forward-compat пункт 4):

  • Миграция 094_profile_can_spawn (SQLite + PG, default true/TRUE — backward-compatible). Колонка задаёт, имеет ли профиль право порождать дочерних подагентов.

  • Post-filter excludeSpawnForNoSpawnProfile (internal/agent/subagent_profile.go): при !profile.CanSpawn spawn-tool убирается из registry child’а (после depth-guard и intersection). spawn_cancel остаётся — агент на любой глубине должен управлять in-flight детьми. Default true сохраняет прежнее поведение; false полезен для «листьев» (researcher/reviewer), чтобы не плодили неконтролируемых детей.

  • Builtin-защита: can_spawn — peripheral-поле (как enabled/sort_order), НЕ входит в ядро builtin-защиты. Оператор может ограничить seed-профиль без клонирования.

  • Бейдж «без делегирования» в карточках библиотеки и подключений.

  • Документация: docs/guide/subagents.md — пользовательское руководство (named vs ad-hoc, библиотека, поля профиля, подключение, вызов, observability, рекомендации, шаблон своего профиля).

  • Тесты (~10 новых): editor — default/reflects/toggle can_spawn, numeric-поля в onSave, wildcard submit, parseWarn; store — persist/read/update can_spawn + seed-default; handler — create с can_spawn, default при отсутствии, builtin peripheral update; agent — excludeSpawnForNoSpawnProfile (CanSpawn=false убирает spawn, spawn_cancel остаётся; nil/ad-hoc no-op; nil-args safe).

  • Проверки: golangci-lint run ./... — 0 issues; go vet ./... — чисто; go test ./internal/... — 27/27 пакетов зелёные; npm run check — 0 errors; npm test — 487/487 passed; make build-cross — 6 бинарников.

  • ocr review (3 прогона, 46 файлов, ~104/128/комментарии): iter1 — устранены 3 STAGE0-3 бага (CHANGELOG-заявки ранее не закрыты): truncateSummary byte→rune (internal/agent/subagent.go), globalConcurrentLimit settings-hot-path → lazy-cache через sync.Once (SubagentManager), syncSpawn без observability/panic-recovery → добавлены CreateSubagentRun/FinishSubagentRun + defer recover() (internal/agent/spawn_tool.go). Frontend мини-фикс: модалка DELETE при ошибке теперь остаётся открытой (onConfirm: Promise<boolean>). iter2 — устранены: общий хелпер truncateUTF8(s, maxRunes, marker) (internal/agent/truncate.go) консолидирует 3 byte-based truncation-функции (truncateSubagentResult/truncateFieldForPrompt/truncateSummary) — одинаковый bug-class «byte-budget вместо rune-budget» для кириллицы/CJK/эмодзи; doc-fix SetSubagentProfiles (упразднить ссылку на несуществующий SetCustomIdentity). Отклонены (с обоснованием): can_spawn как inbound-ACL (неверная интерпретация — это outbound-capability, патч ревьюера сломал бы leaf-профили), XSS через profile_name в ChatToolPanel (Svelte экранирует по умолчанию), singleflight для profilesCache (over-engineering для MVP), caller-ctx в enabledProfiles (documented trade-off), per-row error в deleteProfile (enhancement, не баг).

  • Named subagents, Этап 0 + Этап 3: safety hardening + интеграция с agent-loop (ADR 2026-06-24-named-subagents). Этап 0 устраняет 6 pre-existing safety-багов, которые становились критичными при учащении делегирования; Этап 3 подключает именованных «специалистов» к agent-loop. Этапы 1 (Store + миграции) и 2 (backend API + каталог) были реализованы ранее. Этап 4 (frontend) реализован в #219 (см. выше).

  • Этап 3 — Интеграция:

  • Soul профиля в child (internal/agent/context.go): поле CustomIdentity в ContextBuilder; buildIdentity использует его вместо стандартной "You are X, an AI assistant" — туда попадает profile.system_prompt + опционально ## Output Contract (output_contract). Секция identity имеет mode: PromptModeUnset → рендерится даже в Minimal (куда child попадает), а safety/security/anti_narration остаются отдельными секциями и не перебиваются soul (jailbreak-защита).

  • «Визитки» в системном промпте (buildSubAgentGuidance): список подключённых профилей (- \slug`: description+ опц.output: contract) + инструкция вызова spawn({ subagent: “”, prompt })`. LLM осознанно видит, кого можно позвать.

  • Параметр subagent в spawn-tool (internal/agent/spawn_tool.go): slug → SpawnWithProfile; miss → ошибка со списком доступных + Levenshtein-hint «did you mean X?» (distance ≤ 2). Без slug — прежний ad-hoc spawn (backward-compatible).

  • SpawnWithProfile (internal/agent/subagent.go): snapshot профиля в SubagentTask, резолв модели через ResolveProviderForModel(profile.ModelID) (глобальный реестр, не зависит от доступа родителя к модели), temperature-override (profile.Temperaturespec.Temperature *float64), intersection toolset (profile.tools_whitelist ∩ parent.Tools), принудительное исключение интерактивных tools (ask_user — async-child не имеет канала ответа), observability-hook (subagent_runs: INSERT на spawn, UPDATE на completion с tokens). Snapshot-семантика: disable/delete профиля во время run — run завершается со снимком.

  • Temperature *float64 в AgentRunSpec (internal/agent/types.go): nil = прежнее поведение (по ThinkingLevel). applyThinkingLevel изменён на сигнатуру (req, spec) — при non-nil spec.Temperature переопределяет ThinkingLevel-маппинг (без reasoning_effort при override).

  • Кэш профилей в AgentLoop (internal/agent/loop.go): TTL 30с, enabledProfiles(agentID) — иначе N+1 JOIN на каждый build системного промпта. ListEnabledProfilesForAgent вызывается в buildSubAgentGuidance и spawn-lookup.

  • LLM-лог child-path: раньше child-run (executeChild/syncSpawn) вообще не попадал в llm_request_log. Теперь provider оборачивается в LoggingProvider, strategy = "subagent:<slug>" (named) / "subagent" (ad-hoc) через ContextWithStrategy.

  • WebSocket profile поле: subagent_spawned/subagent_completed несут имя профиля для UI (Этап 4).

  • Truncation результата child (internal/agent/actor.go): subagent_max_result_chars (default 8000) — результат обрезается с маркером [truncated] до инъекции в родителя. Защита от раздутия контекста длинными выводами специалистов.

  • Консолидация prepareChildRun (internal/agent/subagent.go): единый путь сборки AgentRunSpec для async (executeChild) и sync (syncSpawn) spawn’а. Раньше они дублировали сборку registry/messages с разной формулой depth и без поддержки профилей.

  • Agent-слой helpers (internal/agent/subagent_profile.go): parseToolsWhitelist (wildcard ["*"]/точные/prefix-шаблоны mcp_*), applyProfileIntersection, excludeInteractiveTools, profileCustomIdentity.

  • Этап 0 — Safety hardening:

  • Depth-guard fix: registerToolsForAgent принимает depth int; spawn/spawn_cancel регистрируются только при depth < MaxSpawnDepth. Раньше NewSpawnTool всегда получал depth=0, а проверка в executeChild была мёртвой → рекурсию сдерживало только хрупкое отсутствие *SessionActor в child-run.

  • MaxChildrenPerAgent enforcement: счётчик spawnedTotal (atomic.Int32) в SessionActor, проверка в tryReserveSlot, reset в cleanupSubagents. Раньше поле было мёртвым.

  • Глобальный concurrency cap: setting subagent_max_global_concurrent (default 16) — верхний потолок параллельных субагентов во всех агентах. Раньше лимит был только per-actor.

  • Atomic slot reservation: tryReserveSlot под одним мьютексом — check лимитов + регистрация task + инкремент spawnedTotal. Раньше check (m.mu) и increment (pendingMu) были разнесены → race с превышением лимита на 1-2 под нагрузкой.

  • cfg.Enabled enforcement: spawn-tool не регистрируется при subagents.enabled=false. Раньше поле вводило в заблуждение.

  • Snapshot semantics + graceful cleanup: SubagentTask.Profile snapshot; cleanupSubagents ждёт детей (WaitForChildren, 3с) и помечает зависшие runs как orphaned (MarkOrphanedSubagentRuns).

  • PromptMode гейт fix: resolvePromptMode гейт b.Mode != 0b.Mode != PromptModeUnset. Раньше явная установка Mode = PromptModeMinimal (=0) не работала через != 0-гейт (выполнялось только потому, что child передаёт канал "subagent"). Конструкторы ContextBuilder явно ставят Mode: PromptModeUnset (zero value 0 = Minimal, что ломало бы fallback).

  • Новые settings: subagent_max_global_concurrent (1–64, default 16), subagent_max_result_chars (100–100000, default 8000) — группа timeouts.

  • Тесты (~20 новых): helpers (whitelist parse/wildcard/prefix, intersection wildcard/exact/prefix, interactive exclusion, profileCustomIdentity), CustomIdentity (подмена/empty-fallback/channel), визитки (with/without profiles/no-spawn), PromptMode гейт, truncation (unit+inject), levenshtein/hint/formatSlugList, depth-guard (registerToolsForAgent depth 0 vs 1), cfg.Enabled disabled, MaxChildrenPerAgent (4-й отклоняется), spawnedTotal reset, concurrent no-overbook (20 goroutine × MaxConcurrent=3, без race), SpawnTool subagent param (unknown slug + hint, known slug sync), SpawnWithProfile soul в промпте (перехват запроса, output_contract). go test -race -count=1 ./internal/agent/... — зелёный.

  • Проверки: golangci-lint run ./... — 0 issues (на нашем коде); go vet ./... — чисто; go test ./internal/... — 27/27 пакетов зелёные; go test -race ./internal/agent/... — без DATA RACE; npm run check — 0 errors; make build — ок; make build-cross — 6 бинарников собраны.

  • ocr review (2 прогона): устранены — регрессия SpawnCancel (исчезал на MaxSpawnDepth, child терял управление in-flight детьми), race CreateSubagentRun-после-go-runChild (RunID=0 → finishRun no-op), profilesCache memory-leak (stale entries не удалялись), O(n) scan в tryReserveSlot (→ O(1) perActor/global counters), silent clamping maxIter/timeout (→ slog.Warn), визитки unbounded/markdown-injection (cap 20 + truncation по rune-boundary + slug-escape + output_contract в fenced-блоке), CustomIdentity cache-инвариант (loadAgent), slot-leak-on-panic (defer releaseSlot), truncation mid-UTF-8 (→ rune-boundary), settings.GetInt hot-path (→ lazy-кэш в actor), levenshtein case-insensitive, NewSpawnTool делегирует NewSpawnToolWithDepth. Отклонены (с обоснованием): singleflight для thundering-herd (over-engineering, данные идемпотентны), caller-ctx в enabledProfiles (недоступен; context.Background+timeout приемлемо для MVP), TTL-invalidation на Attach/Detach (documented snapshot-trade-off, 30с окно), полный markdown-escape # в CustomIdentity (сломал бы легитимное форматирование; write-time sanitize в handler’е достаточна), русские→английские комментарии (AGENTS.md требует русский).

Fixed

  • Depth-guard subagents сломан (ADR ). registerToolsForAgent не передавал depth → child всегда получал SpawnTool с depth=0 → проверка depth >= MaxSpawnDepth была мёртвой. Рекурсию сдерживало только хрупкое отсутствие *SessionActor в child-run (любая правка context-passing сломала бы последний барьер). Исправлено: depth-aware регистрация.
  • MaxChildrenPerAgent — мёртвое поле (ADR ). Читалось из БД/UI, но нигде не enforced. Реализован счётчик + проверка.
  • Race в slot-reservation (ADR ). Проверка лимита (ActiveCountByActor под m.mu) и increment (activeSubagents++ под pendingMu) были разнесены → превышение лимита на 1-2 под нагрузкой. Исправлено: tryReserveSlot под одним мьютексом.
  • subagents.enabled не enforced. Поле вводило в заблуждение: enabled=false не запрещал spawn.
  • Дублирование syncSpawn/executeChild (ADR ). Два пути сборки child-run с разной формулой depth и без поддержки профилей у syncSpawn. Консолидировано в prepareChildRun.
  • Child-run не логировался в LLM-лог. executeChild/syncSpawn не оборачивали provider в LoggingProvider → запросы субагентов не попадали в llm_request_log. Теперь оборачиваются + strategy.
  • PromptMode != 0 гейт не видел явный Minimal. resolvePromptMode использовал b.Mode != 0, что не различало «не задано» и PromptModeMinimal (=0 после iota от Unset=-1).

Реализован backend-слой для именованных subagent-профилей (глобальный реестр «специалистов» со своей «душой», model/temperature, tools-whitelist), который Этап 3 подключит к agent-loop. Этап 1 (Store + миграции 091/092/093 + структуры) был реализован ранее; Этап 0 (safety hardening) runtime-независим от Этапа 2.

  • Декларативный каталог tools (internal/agent/tool_catalog.go): единый источник правды «permission-flag → tool-id» (34 builtin + 3 prefix-шаблона mcp_*/email_send_*/email_inbox_* для динамических имён). Заменяет рассеянный императивный маппинг в loop.go/runner.go для задач валидации/UI. Поля Name, PermissionFlag, Interactive (только ask_user — исключается из registry child’а на Этапе 3), ReadOnly, AlwaysOn (tool_log/message_get/todo_write — always-on, whitelist no-op), Description. Helpers: IsKnownTool (exact + prefix-match), ValidateToolsWhitelist (парсит JSON, пропускает wildcard "*", перечисляет неизвестные имена), CatalogForUI (sorted copy), InteractiveToolNames.

  • HTTP-handler internal/server/handler/subagent_profile.go: CRUD профилей (global-admin-only через RequireGlobalAdmin), per-agent connections (чтение — член агента, запись — RequireAgentAdmin), observability-runs (только чтение). Валидация (ADR «Runtime sanitization полей профиля»): IsValidSlug (regex ^[a-z][a-z0-9_-]{0,63}$), max-length полей (description≤1024, system_prompt≤8192, output_contract≤1024, display_name≤128), strip control chars + невидимых Unicode (zero-width, bidi overrides, BOM — защита от «Trojan Source»), запрет role-префиксов (system:/assistant:/user: в начале строки, Unicode-aware trim), whitelist-валидация через каталог, mcp_whitelist JSON-структура. is_builtin protection: delete централизован в Store (ErrBuiltinProfileProtected → 403, DB-invariant, не только handler); update ядра (name/system_prompt/whitelist’ы) заблокирован в handler. Конфликты UNIQUE-name → 409. ListRuns: cap limit≤500, offset≤10000, валидация status по CHECK-констрейнту миграции 093 (400 при превышении, не молчаливый clamp).

  • Маршрутизация (router.go, main.go): глобальная группа /api/v1/subagent-profiles (CRUD + /tool-catalog доступен без admin-прав для UI) и agent-scoped /api/v1/agents/{agentID}/subagent-profiles (list, runs, attach/detach/setEnabled). NewRouter расширен позиционным параметром.

  • TS API-клиент (web/src/lib/api/{types.ts,endpoints.ts): типы SubagentProfile, AgentSubagentConnection, SubagentRun, ToolCatalogEntry (nullable-поля → number | null); группа api.subagentProfiles.* (list/get/create/update/delete/getCatalog) + agent-scoped (listForAgent/listRuns/attach/detach/setEnabled). UI-страницы (Svelte) — Этап 4.

  • Тесты: каталог — покрытие всех 34 имён, prefix-match, валидация whitelist (wildcard/неизвестные/невалидный JSON); handler — CRUD-lifecycle, builtin-rename/delete → 403 (через centralized store-sentinel), slug-validation, whitelist-validation, max-length, duplicate → 409, role-prefix (включая NBSP/ZWSP/BOM-bypass), control-chars stripping, connections lifecycle, runs с фильтрами, status/limit/offset ceilings. Store — centralized ErrBuiltinProfileProtected coverage.

  • Проверки: golangci-lint run ./... — 0 issues; go vet ./... — чисто; go test ./internal/... — 27/27 пакетов зелёные; npm run check — 0 errors; make build — ок. ocr review (4 прогона): устранены centralized builtin-protection, CR/NEL line-terminators в role-prefix, validation/storage divergence whitelist, success-path 500 на attach/detach, silent limit cap → 400, mutable maps → struct{}, inline id-парсинг по конвенции проекта, Unicode-aware prefix-trim, generic error messages без reflection attacker-контента. Отклонены (с обоснованием): optimistic locking (out-of-scope Этапа 2, общая проблема CRUD), info-leak whitelist-сообщений (operator-helpful для admin-only endpoint).

  • Внешние ссылки в сообщениях чата открываются в новой вкладке. Раньше markdown-ссылки (включая autolinks вида <https://…>) рендерились без target="_blank" и открывались в текущей вкладке, теряя состояние SPA. Теперь MarkdownRenderer.svelte навешивает target="_blank" + rel="noopener noreferrer" на все внешние http:/https:-ссылки после DOMPurify.sanitize через локальную DOM-обработку (externalizeLinks).

  • Безопасность: пост-обработка идёт на уже очищенном DOM (после sanitization), так что javascript:/data:text/html URL уже вырезаны — XSS-поверхность не расширяется. Локальный подход (без глобальных DOMPurify-хуков) не влияет на других потребителей DOMPurify.sanitize и не накапливает состояния при HMR.

  • Исключения: ссылки на скачивание файлов (/files/download…, атрибут download) не получают target="_blank" — файл должен скачиваться; внутренние ссылки (/page, #anchor, относительные) и mailto:/tel: также открываются в текущей вкладке.

  • Сохранение rel: существующие rel-токены автора (например nofollow) не перезаписываются, а мержатся с noopener noreferrer.

  • Тесты: 8 новых кейсов в MarkdownRenderer.test.ts (markdown-ссылка, autolink, http, внутренняя, якорь, mailto, download-ссылка, регресс javascript:). npm run check — 0 errors, npm test — 428/428 passed, ocr review — 0 замечаний.

Changed

  • Переименование UI-разделов агента: «Инструменты» → «Постоянная память», «Память» → «Динамическая память». Названия разделов не отражали способ попадания контента в LLM-запрос и вводили в заблуждение (в приложении уже есть понятие tool-calls LLM, к описаниям промпта отношения не имеющее). Новая номенклатура разделяет «постоянную» часть (всегда в системном промпте) и «динамическую» (выборочно подгружаемую по релевантности).
  • URL-маршруты (web/src/routes/agents/[id]/): tools/permanent-memory/ (включая подмаршруты new/ и [toolId]/[itemId]/); memory/dynamic-memory/ (включая docs/). Переименование выполнено через git mv с сохранением истории.
  • 301-редиректы со старых URL: для обратной совместимости закладок/ссылок в каталогах tools/, tools/new/, tools/[toolId]/, memory/, memory/docs/ оставлены +page.ts load-функции, выбрасывающие redirect(301, …). Query-параметры (?tab=, ?source_type=&source_id=) переносятся на новый путь через url.search. Сборка npm run build подтверждает, что SvelteKit валидирует маршруты только по +page.ts — пустой +page.svelte не требуется.
  • Меню агента (+layout.svelte): навигационные ключи key: 'tools'/'memory''permanent-memory'/'dynamic-memory', лейблы «Инструменты»/«Память» → «Постоянная память»/«Динамическая память». Иконки заменены на более семантичные: шестерёнка → archive-box (Heroicons outline, символизирует «архив/хранилище»), лампочка → bolt (молния, символизирует «быструю динамическую подгрузку»). Подстановка href/breadcrumb/active-state в currentPath === entry.href автоматически подтянула новые значения — ручных правок в механизме подстановки не потребовалось.
  • Описания разделов:
  • Постоянная память (permanent-memory/+page.svelte): в SectionCard.subtitle добавлено пояснение: «Записи из этого раздела целиком добавляются в системный промпт при каждом запросе агента. Внимание: большой объём данных увеличивает расход токенов и стоимость запросов к LLM.» В PermanentMemoryEditor.svelte под <textarea> добавлена подсказка о попадании контента в каждый запрос. Предупреждение о превышении порога (>20 записей) переформулировано: «Каждая запись попадает в каждый запрос агента и увеличивает расход токенов.» Empty-state: «Добавьте описания, которые всегда должны быть в контексте агента».
  • Динамическая память (dynamic-memory/+page.svelte): добавлен info-блок над MemoryOverview с заголовком «Динамическая память» и текстом «Факты и документы из этого раздела подбираются и подгружаются в контекст агента выборочно при каждом запросе (на основе релевантности), а не попадают в промпт целиком — в отличие от постоянной памяти.».
  • TS-типы и API-клиент (lib/api/): interface AgentToolinterface PermanentMemoryItem (поля без изменений). Группа методов api.agentToolsapi.permanentMemory (сигнатуры list/create/update/delete без изменений). audit.agentTools (метод аудита tool-calls LLM) и auditTab === 'tools' (вкладка аудита) — НЕ затронуты, другой доменный смысл. lib/components/ToolEditor.sveltePermanentMemoryEditor.svelte (с дефолтным title='Запись').
  • Backend НЕ менялся. Эндпоинты /api/v1/agents/{id}/tools и /api/v1/agents/{id}/memory/... сохранены для обратной совместимости с сервером; переименование backend-маршрута вынесено в TODO в endpoints.ts для отдельной задачи (ренейм затронет handler, router, миграции логирования — отдельная проработка).
  • Breadcrumb/<title>/toast/empty-state/confirm-модалки обновлены по новому доменному имени: «Создать запись», «Редактирование записи», «Запись удалена», «Запись «{title}» будет удалена», «← Вернуться к постоянной памяти», счётчик «запись/записи/записей». Переменные tool/tools/toolIditem/items/itemId в новых файлах.
  • Сопутствующий фикс: баг русской плюрализации в счётчике записей (n === 1 ? 'запись' : n < 5 ? 'записи' : 'записей' неправильно обрабатывал 11–14 и 21–24). Добавлен helper pluralize(n, forms), учитывающий правило последних двух цифр (mod10, mod100). Баг присутствовал и в оригинальном tools/+page.svelte (для «инструмент/инструмента/инструментов»), но исправлен только в новом месте.
  • Документация: docs/webui-structure.md — обновлены пути и названия упоминаний UI-раздела памяти (/agents/[id]/memory — Память/agents/[id]/dynamic-memory — Динамическая память; строка в feature-таблице).
  • ocr review: 3 прогона. Устранены: пустые +page.svelte-файлы в каталогах редиректов (SvelteKit валидирует маршрут только по +page.ts со redirect()), баг плюрализации, TODO-комментарий про backend-URL. Оставлены как copy-paste-паттерн всего проекта: a11y-замечания про <label for=> (309 аналогичных warnings в npm run check), i18n-абстракция (русские строки — проектное решение AGENTS.md), $effect-синхронизация form с initialData (в нашем случае parent рендерит компонент только при готовом initialData).
  • Проверки: npm run check — 0 errors (309 warnings — все pre-existing), npm test — 415/415 passed, npm run build — SvelteKit build OK (маршруты и редиректы валидны), go test ./internal/... — все пакеты green (Go-код не менялся, проверено для полной верификации).

Fixed

  • Висит «Загрузка…» на /agents/{id}/memory?tab=scratchpad при наличии дубликатов key. API возвращал 200 OK с массивом записей (38 строк с одинаковым key="versions-baseline"), но фронт зависал в состоянии «Загрузка…» — Svelte 5 ломал reconcile из-за each-итерации с (entry.key) при дублирующих ключах (изменение появилось в коммите e792d8c, заменившем each без ключа на keyed). Корневая причина дублей в БД — ScratchpadWrite использовал INSERT ... ON CONFLICT(agent_id, user_id, key) DO UPDATE, но NULL != NULL в UNIQUE-индексе (как SQLite, так и PostgreSQL по умолчанию) → для agent-scope записей (user_id IS NULL) UPSERT не находил конфликт и каждый раз вставлял новую строку.

  • Backend (internal/store/{sqlite,postgres}.go, ScratchpadWrite): UPSERT переписан с ветвлением по userID. Для userID != nil остаётся стандартный ON CONFLICT(agent_id, user_id, key). Для userID == nil используется ON CONFLICT(agent_id, key) WHERE user_id IS NULL — атомарно, race-safe, сохраняет created_at. Конфликт-цель и аргументы вынесены в переменные для DRY.

  • Миграция 090 (internal/store/{migrations,pgmigrations}/090_scratchpad_dedup.{up,down}.sql): дедупликация существующих данных через ROW_NUMBER() OVER (PARTITION BY agent_id, user_id, key ORDER BY id DESC) (оставляет последнюю запись по id) + создание partial unique index memory_scratchpad_agent_null_user_key_uniq ON memory_scratchpad(agent_id, key) WHERE user_id IS NULL — гарантирует уникальность agent-scope записей на уровне схемы и включает новый ON CONFLICT-путь. SQLite-миграция выполняется в неявной транзакции golang-migrate (см. комментарий в миграции 078); PG — в implicit transaction драйвера. Down-миграция дропает индекс с предупреждением, что после отката agent-scope записи снова начнут дублироваться.

  • Frontend (web/src/lib/features/memory/scratchpad/ScratchpadTab.svelte): each-key оставлен как (entry.key) (теперь безопасно — partial index гарантирует уникальность), добавлен badge с updated_at через shared utility formatDateTime (консистентность с SkillEditor, DocsTab).

  • Тесты Go (6 новых в internal/agent/tools/scratchpad_test.go): TestScratchpadWrite_UserIDNil_Upserts (3 последовательные записи → 1 строка), TestScratchpadWrite_UserIDNonNil_Upserts (регрессия для user-scope), TestScratchpadWrite_UserIDIsolationIsolated (system и user scope не затирают друг друга), TestScratchpadWrite_UserIDNil_ConcurrentRaceSafety (20 горутин → ровно 1 строка, под -race). Все проходят с race detector.

  • Тесты фронт (5 новых в ScratchpadTab.test.ts): рендер с уникальными key, пустой scratchpad, состояние «Загрузка…», presence/absence «Очистить всё», отображение expires_at.

  • ocr review: 8 прогонов. Исправлены все актуальные замечания: race-safety (DELETE+INSERT → ON CONFLICT с partial index), code duplication (conflictTarget/args вынесены в переменные), godoc на английском, консистентность SQL-комментариев (Russian для inline reasoning, English для godoc), window-function dedup вместо self-join, идемпотентность down-миграции, предупреждение про ACCESS EXCLUSIVE lock на PG. Informational nitpicks (PG integration test infra, rename index for “consistency”) — оставлены.

  • Бесконечный спиннер на /agents/{id}/skills/improvementsloading никогда не сбрасывался из-за race в cancellable-request (регрессия v0.142.25, ). API возвращал 200 OK с валидным массивом proposals, но список не рендерился — спиннер крутился бесконечно. Корневая причина: loadProposals и loadAnalyzeStatus делили один монотонный счётчик req (createCancellableRequest) и обе вызывались в Promise.all из onMount. Синхронно до первого await выполнялось: loadProposals()req.next() (seq=1, token=1) → await; затем loadAnalyzeStatus()req.next() (seq=2, token=2) → await. Когда loadProposals завершался, req.isStale(1) = 1 !== 2 = true, поэтому proposals = res дропался (early return), а if (!req.isStale(myToken)) loading = false в finally не выполнялся — loading навсегда оставался true. Автор #181 знал об этом классе race и защитил loadAgentSkills отдельным cancelled-guard’ом (комментарий в коде: “иначе в Promise.all монотонный req.next сразу сделает этот токен stale”), но loadAnalyzeStatus подключили к тому же req, повторно введя баг. Race недетерминирован по таймингу сети: при тестировании #181 analyzeStatus resolving’ся медленнее listProposals, баг не всплывал; с другим таймингом (тёплый кэш GET /analyze, быстрый ответ) analyzeStatus опережал и ломал loadProposals.

  • Фикс: отдельный cancellable-request instance statusReq для loadAnalyzeStatus (web/src/routes/agents/[id]/skills/improvements/+page.svelte). Изоляция token-space устраняет cross-invalidation: loadProposals keeps req, loadAnalyzeStatus использует statusReq. visibilitychange по-прежнему инвалидизирует только свои устаревшие status-запросы (race-protection при множественных возвратах фокуса сохранён).

  • Поведение после фикса: loadProposals resolves → req.isStale(1) = false → данные присваиваются, loading=false; loadAnalyzeStatus resolves → statusReq.isStale(1) = false → статус присваивается.

  • Горизонтальный скролл в diff на странице улучшений навыков — перенос строк вместо overflow-x-auto. На /agents/{id}/skills/improvements раскрытый diff рендерился в <pre> без управления переносом: браузерный дефолт white-space: pre не рвал длинные строки, а родительский <div> с overflow-x-auto добавлял горизонтальный скролл внутри блока — неудобно для длинного контента навыков.

  • <pre> diff (web/src/routes/agents/[id]/skills/improvements/+page.svelte): добавлены whitespace-pre-wrap break-words — сохраняет значащие отступы/пробелы и маркеры +/, переносит длинные строки и рвёт неразрываемые токены; overflow-x-auto с родительского <div> убран (контент переносится, скролл больше не нужен).

  • Текст анализа LLM (analysis.analysis): к whitespace-pre-line добавлен break-words — подстраховка от длинных неразрываемых токенов в LLM-ответах. Совпадает с устоявшейся конвенцией проекта (settings/llm-log, memory/docs, PromptPreviewModal — все используют whitespace-pre-wrap break-words).

  • Парсер reflection не принимал analysis как массив; proposal никогда не создавался. Диагностика прод-инстанса (PostgreSQL, скилл card-text: 19 завершённых runs, skill_reflection_enabled=true, pending proposals=0) показала: анализ скиллов запускался, доходил до LLM, но ответ стабильно отбрасывался парсером. reflectionResult.Analysis имел тип string, а LLM (glm-5.2) возвращал analysis как JSON-массив ["…","…"] — естественное прочтение фразы «bullet list of findings» из промпта. json.Decoder падал с cannot unmarshal array into Go struct field reflectionResult.analysis of type string, proposal не создавался. Корневая причина + сопутствующие UX-проблемы:

  • Custom UnmarshalJSON для reflectionResult (internal/agent/skill_reflection.go). Толерантно принимает analysis как string | []string | []any; массив склеивается в строку через \n (UI рендерит analysis с whitespace-pre-line, так что каждый bullet на отдельной строке). Helper analysisRawToString обрабатывает edge cases: null-элементы пропускаются (не превращаются в пустые строки — json.Unmarshal([]byte("null"), &str) не ошибается, оставляя str=""), числа/bool stringify через fmt.Sprint (defensive), объекты внутри массива — stringify через fmt.Sprint (defensive, LLM редко их кладёт). Объект/scalar на верхнем уровне analysis → error с понятным сообщением.

  • Промпт: явный формат analysis. В buildReflectionPrompt строка формата ответа уточнена: "<single STRING with bullet findings separated by \n — NOT a JSON array>". Снижает расхождение с LLM, делает контракт однозначным для других моделей.

  • Логирование reflection-запросов в llm-лог (llmlog.WrapProvider). reflectSkill вызывал bare j.provider — reflection-запросы не попадали в llm_request_log / llm-logs, делая диагностику вслепую. Добавлено опциональное поле logger *llmlog.Logger + SetLogger в SkillReflectionJob; в reflectSkill провайдер оборачивается в LoggingProvider с strategy="skill_reflection" (маркер в llm_request_log.strategy, по которому reflection отличим от обычных чатов). В main.go: reflJob.SetLogger(llmFileLogger). Race-safety: j.logger читается в local variable ОДИН раз в начале reflectSkill (несинхронизированное поле может быть перезаписано SetLogger — snapshot устраняет TOCTOU между проверкой if и использованием). nil-логгер (обратная совместимость со старыми тестами) — no-op.

  • GET /skills/improvements/analyze (AnalyzeStatus) + UI disable кнопки. Кнопка «Запустить анализ» оставалась активной даже когда reflection выключен или недоступен — пользователь жмёт и получает 403/503, плохой UX. Новый эндпоинт AnalyzeStatus возвращает {available, enabled}: available = reflector создан (provider настроен), enabled = setting skill_reflection_enabled=="true" (fail-closed при ошибке GetSetting, slog.Debug для диагностики «кнопка disabled у всех»). Handler не валидирует agentID — статус глобальный per-instance, parent middleware проверяет membership. Роут GET /analyze зарегистрирован ДО /{proposalID} (статический сегмент не должен поглощаться параметром). На фронте: кнопка disabled когда анализ выключен/недоступен/идёт ИЛИ пользователь не agent admin (canEditAgent — non-admin agent_user раньше видел кликабельную кнопку, которая 403-ила). Tooltip меняется по причине disabled. Статус перечитывается при visibilitychange (админ в другой вкладке включил reflection → кнопка оживёт без reload). Cancellable request token в loadAnalyzeStatus (как в loadProposals — race при множественных mount/visibilitychange).

  • Тесты (15 новых): TestReflectionResult_UnmarshalJSON_{AnalysisAsString, AnalysisAsStringArray, AnalysisAsNumberBoolArray, AnalysisEmptyArray, AnalysisAbsent, AnalysisAsObject_Error, AnalysisWithBraceInValue, MarshalJSON_PreservesString}; TestAnalysisRawToString (table-driven: string, string_array, number_array, mixed_array с null, object_array, empty_array, null, object_error, number_scalar_error); TestParseReflectionResponse_{AnalysisAsArray, AnalysisAsArrayWithMarkdownFences}; TestSkillReflection_CreatesProposalWithArrayAnalysis (end-to-end: ReflectAgent с array analysis → proposal создаётся); TestSkillReflection_LogsViaLogger (проверка записи в llm_request_log со strategy="skill_reflection"); TestSkillReflection_NoLogger_BackwardCompat; TestAnalyzeStatus_{EnabledAndAvailable, Disabled, NoReflector_NotAvailable}; TestRouter_AnalyzeRouteResolution (regression: GET /analyze → AnalyzeStatus, не GetProposal(“analyze”)).

  • ocr review (13 замечаний, 10 исправлены не-low): idiomatic chi pattern (r.With(mw).Method вместо mw(handler).ServeHTTP), regression-тест route-resolution, cleanup AnalyzeStatus (убран неиспользуемый agentID-парсинг, writeJSON helper для X-API-Version, slog.Debug при ошибке GetSetting), SetLogger race-snapshot, UI: analyzeTitle порядок проверок (сначала disabled-причины, потом analyzing — консистентно с analyzeDisabled), cancellable token в loadAnalyzeStatus, role-check для non-admin (HIGH — убрал авторизационный gap), refresh статуса при visibilitychange. Low (retry-multi-row в llm_log, naming) — оставлены.

  • UI-тоггл для skill_reflection_enabled в Advanced Settings. Флаг саморефлексии скиллов существовал в БД и читался в main.go (cron) и SkillsHandler.AnalyzeNow, но не был exposed в UI — пользователь не мог его включить. Кнопка «Запустить анализ» из v0.142.21 возвращала 403 без возможности это исправить.

  • Backend (Go): skill_reflection_enabled добавлен в settings.ParamRegistry (internal/settings/defs.go) — группа skills, default false. Тип bool добавлен в AdvancedHandler.validateParam (internal/server/handler/advanced.go) — валидация через strconv.ParseBool. Нормализация: Update каноникализирует bool через strconv.FormatBool перед SetSetting — неканоничные формы ("1", "TRUE", "T"), принимаемые ParseBool, сохраняются как "true"/"false", иначе downstream-сравнение == "true" (main.go cron gate, AnalyzeNow, WebUI toggle) молча ломалось. Сообщение 403 в AnalyzeNow уточнено: «enable in Settings → Advanced → Skills».

  • Frontend: в /settings/advanced (+page.svelte) условный рендер — для type==='bool' тоггл (role=switch, aria-label, focus-visible ring), для int/float — number-input, {:else} fallback с предупреждением для будущих типов. Группа «Навыки» добавлена в expandable-список. isBoolOn helper + нормализация bool при loadAdvanced/resetAdvancedParamadvancedEditValues всегда содержит canonical "true"/"false", независимо от того, что лежит в БД (защита от direct DB writes / imports).

  • Тесты: TestAdvanced_Update_{ValidBool_True, ValidBool_False, BoolNormalizesNonCanonical, InvalidBool_Returns400, Get_IncludesSkillReflection} (нормализация 8 нестандартных форм → canonical); TestParamRegistry_{Groups, AllDefaultsValid, RequiredFields} обновлены для bool-типа и группы skills.

  • ocr review: три прогона. Исправлены: bug нормализации bool (#1 high — ParseBool принимал "1"/"TRUE", но сохранял raw, ломая downstream == "true"), нормализация при load/reset на фронте (flip-bug при нестандартном stored value), a11y toggle (aria-label, focus-visible ring), explicit fallthrough для типов, уточнённое error-message, single-source error string (errSkillReflectionDisabled). #4 (isChanged vs save-сравнение) — false positive (желаемое поведение: stored≠default → badge «изменено» корректен).

Added

  • Пользовательский дропдаун (аватар → Профиль / Администрирование / Выход) на всех авторизованных страницах. Раньше этот блок (ch-user-dropdown) существовал только в шапке чата (ChatHeader.svelte) — на остальных страницах (PageHeader: agents, settings, profile, dashboard, users и т.д.) пользователь не видел, под кем вошёл, и не мог выйти без возврата в чат.

  • Новый переиспользуемый компонент UserMenu.svelte (web/src/lib/components/), вынесенный из ChatHeader: состояние userDropdownOpen, загрузка профиля (api.profile.get()displayName, auth.user.id → аватар), handleLogout() (auth.logout() + goto('/login')), click-outside через <svelte:window onclick>. Меню: Профиль (/profile), Администрирование (/settings, под {#if isAdmin()}), Выход. Класс переименован ch-user-dropdownuser-menu.

  • PageHeader.svelte: <UserMenu /> встроен в блок actions (теперь рендерится всегда, рядом с опциональным snippet) — покрывает все не-chat страницы.

  • ChatHeader.svelte: инлайненный блок заменён на <UserMenu />, удалена выведенная логика/состояние (userDropdownOpen, displayName, userId, loadProfile, handleLogout, ссылка из handleClickOutside) и неиспользуемые импорты (api, auth, isAdmin, ProfileData, goto). Файл похудел на ~144 строки.

  • ocr review: исправлены все замечания — дублирование click-outside (document.addEventListener + <svelte:window> → оставлен только <svelte:window>), мёртвый try/catch вокруг синхронного чтения auth.user (убран), глухой catch {} на api.profile.get() (добавлен console.error). npm run check — 0 ошибок, тесты (410) проходят.

  • Кнопка «Запустить анализ» навыков на странице улучшений. Добавлена возможность запустить анализ навыков агента немедленно, не дожидаясь cron-тикера. Анализ выполняет то же, что и SkillReflectionJob.ReflectAgent, но в пределах только выбранного агента и только навыков, удовлетворяющих условиям cron (не builtin, enabled, ≥10 завершённых запусков за 30 дней, нет pending-proposal). Запуск асинхронный: POST возвращает 202 Accepted, анализ идёт в фоновой горутине, результат (новые proposals) появляется в списке после перезагрузки.

  • Backend (Go): per-agent concurrency guard TryStartReflect/FinishReflect на *agent.SkillReflectionJob (internal/agent/skill_reflection.go) — защищает от двойной траты LLM-токенов при конфликте ручного запуска и cron-тика. Cron-путь (ReflectAll) тоже уважает guard (пропускает агента, если ручной запуск уже идёт). reflectAgentGuarded оборачивает ReflectAgent с recover() + defer FinishReflect — panic в ReflectAgent (misbehaving LLM-провайдер) не роняет процесс и не блокирует агента навсегда. Интерфейс SkillReflector в handler-пакете + setter SetReflector (по образцу ChatHandler.SetConsolidator, т.к. reflJob создаётся позже handler’а в main.go). Метод AnalyzeNow (internal/server/handler/skills.go): nil-reflector → 503 (провайдер не сконфигурирован); feature-флаг skill_reflection_enabled != "true" → 403; TryStartReflect false → 409; иначе горутина с 10-мин контекстом → ReflectAgent → 202. Роут POST /skills/improvements/analyze под RequireAgentAdmin. Общий таймаут вынесен в agent.SkillReflectionTimeout (DRY: cron + ручной запуск).

  • Frontend: метод api.skills.analyzeNow(agentId) (endpoints.ts). Кнопка «Запустить анализ» в шапке /skills/improvements (+page.svelte) — busy-флаг analyzing с индикатором «Анализ выполняется…» в течение окна ожидания (5с), toast.success на 202, toast.error на 403/409/503. Отложенный auto-reload списка через 5с (handle очищается в onDestroy — нет утечки timer’а). Локализованные UI-строки во фронте, стабильные английские ключи в API-ответах.

  • Тесты: TestSkillsHandler_AnalyzeNow_{NoReflector→503, Disabled→403, AlreadyRunning→409, OK→202, InvalidAgentID→400} (мок fakeReflector с channel-синхронизацией для проверки фоновой горутины); TestSkillReflectionJob_TryStartReflect_{Guard, Concurrent} + TestReflectAgentGuarded_RecoversPanic_ReleasesGuard (panic-recovery + снятие guard).

  • ocr review: два прогона. Первый — исправлены: resource-leak setTimeout (capture handle, clearTimeout в onDestroy), panic skips FinishReflect в cron-пути (вынесено в reflectAgentGuarded с recover()), логирование GetSetting error, DRY-таймаут (agent.SkillReflectionTimeout), английские API-сообщения. Второй — исправлены: FinishReflect после recover() (разделены на два defer, чтобы panic в FinishReflect тоже ловился), stale-timer при повторном клике (clearTimeout перед новым setTimeout), визуальный индикатор выполнения, тест на panic-recovery. Low/nit (rename reflectMu, nil-map edge case, Location header, feature-flag robustness как project-wide) оставлены.

Changed

  • Промпт саморефлексии скиллов: полная картина выполнения вместо обрывков. Промпт buildReflectionPrompt (internal/agent/skill_reflection.go) обрезал трассу tool-call’ов до обрывков, что мешало LLM качественно анализировать паттерны ошибок и предлагать улучшения:
  • Шагов на run: 20 → 50. MaxStepsPerRun (дефолт и fallback в NewSkillReflectionJob) увеличен — длинные трассы теперь видны целиком, а не только первые 20 шагов.
  • args (команда) — целиком. ArgsSummary шага раньше обрезался до 80 символов (truncateForPrompt(s.ArgsSummary, 80)), теперь передаётся полностью (до БД-капа maxArgsSummary=500). Обрубок команды мешал LLM понять intent tool-call’а — например, {"cmd":"docker compose -f ... не давал увидеть полную команду.
  • result (вывод) — разумный кап 300. Вместо магического 80 введена именованная константа maxStepResultInPrompt = 300. Вывод tool’а (содержимое файла, stdout) часто большой и шумный, но 80 было слишком жёстко — 300 даёт сигнальную часть без избыточного раздувания промпта.
  • truncateForPrompt → rune-aware. Функция резала по байтам, ломая многобайтовый UTF-8 (кириллица в result/goal/summary оставляла полу-руны перед «…»). Переписана: параметр переименован maxLenmaxRunes, единый контракт через utf8.RuneCountInString, безаллокационная итерация через strings.Builder + WriteRune (вместо []rune(s)). Затронуты все 6 call-site’ов (sk.Content, goal/summary/reflection поля run’а, ResultSummary шага). По итогам ocr review исправлены замечания: semantic mismatch fast/slow path, docstring-консистентность, кросс-пакетная зависимость в комментарии.
  • Тесты: TestBuildReflectionPrompt_StepArgsFullResultCapped (args 200 рун входит целиком, result 400 рун обрезан по 300), TestTruncateForPrompt_RuneSafe (кириллица/эмодзи/mixed — валидный UTF-8, rune count ≤ maxRunes).

Fixed

  • Детали LLM-лога не открывались (404) на странице /settings/llm-log. Список запросов отображался корректно, но при выборе любой записи фронт получал «Не удалось загрузить данные». Причина — рассинхрон пути файла в internal/llmlog/logger.go: writeEntry создавал файл <agentID>/<date>.jsonl, но в БД записывал FilePath БЕЗ расширения (напр. 2/2026-06-19), а LoadEntryBody открывал путь из БД как есть → os.Open падал «нет такого файла» → хендлер возвращал 404. Подтверждено на проде (192.168.2.104): 4384 записи, 100% отказ.

  • Компонент 1 — фикс .jsonl: LoadEntryBody добавляет расширение .jsonl к пути из БД при отсутствии (обратно-совместимо — записи с уже включённым расширением не затрагиваются). БД не мигрируется.

  • Компонент 2 — убран лимит 16 МБ на строку: bufio.Scanner (с молчаливым ErrTooLong) в LoadEntryBody и countLines заменён на bufio.Reader.ReadBytes/String('\n') без верхнего предела. Теперь крупные reasoning/RAG-запросы (история + контекст могут превышать 16 МБ) корректно читаются. Заодно починен скрытый баг: countLines после рестарта занижал счётчик для строк > 16 МБ → file_line коллизия.

  • Компонент 3 — чтение через seek (in-memory индекс смещений): вместо последовательного обхода 158 МБ-файла для строки N теперь строится кэш []int64 (байтовые смещения начал строк), инвалидируемый по mtime+size. После построения чтение любой строки — O(1) seek. Кэш эвictится в CloseStaleHandles (вчерашние файлы) и при ошибке записи в writeEntry.

  • OCR review: исправлены все non-low замечания — TOCTOU (теперь l.mu держится всю операцию readLine, исключая half-written line при конкурентной записи), silent fallback EvalSymlinks(baseDir) (теперь возвращает ошибку, не ослабляя path-traversal проверку), потеря диагностического лога в countLines, неограниченный рост кэша. Low (комментарии) добавлены.

  • Тесты: TestLogger_RoundTrip (регрессия: LogRequest → GetEntry → LoadEntryBody — до фикса красный), TestLoadEntryBody_PathAlreadyHasExtension (обратная совместимость), TestLoadEntryBody_FileNotFound (os.IsNotExist для 404), TestReadLine_LargeLine (строка 17 МБ > старый лимит), TestReadLine_SeekAndCache + TestReadLine_CacheInvalidatedByGrowth, TestBuildOffsets/TestCountLines (edge-cases: пустой файл, без trailing newline).

  • duration_ms=0 для skill_runs на PostgreSQL: ParseTime падал на зоне +HH. На странице /agents/{id}/skills/runs все запуски показывали длительность 0мс, хотя в БД finished_at - started_at = реальные 10-29 минут, а skill_run_steps.took_ms корректны. Причина — в PostgresStore.ParseTime (internal/store/postgres.go): pgx возвращает колонки TIMESTAMPTZ для целочасовых поясов (например Europe/Moscow = +03) в виде 2026-06-19 17:24:27.885477+03 — numeric-offset зона без минут. Layout 2006-01-02 15:04:05Z07:00 требует +03:00, поэтому ParseTime возвращал ошибку → в FinishSkillRun и pgMarkOrphanedSkillRunsRows durationMs оставался 0. SQLite иммунен: там started_at TEXT в RFC3339Nano.

  • Побочный эффект (закрыт тем же фиксом): тот же ParseTime используется в IsExpired для refresh_tokens.expires_at (тоже TIMESTAMPTZ в +03) → refresh-токены могли ошибочно считаться протухшими. Теперь корректно.

  • Фикс: ParseTime переписан — последовательность layouts (RFC3339Nano → ...15:04:05.999999999Z07:00...15:04:05Z07:00), а если зона короткая, она нормализуется (+03+03:00) через package-level regexp pgShortTZRe и PG-layout’ы повторяются.

  • Тесты: TestPostgresParseTime_PgxOutput (таблица из 7 кейсов с реальным выводом pgx: +03, +03:00, -05, с/без микросекунд, Z, RFC3339Nano — все дают один и тот же UTC-инстант) + TestPostgresParseTime_Invalid (мусор → ошибка, регрессия на fallthrough). Существующие round-trip/IsExpired/cross-store тесты не изменились.

  • Решено не делать: backfill исторических duration_ms=0 (по решению владельца) — код починен на будущее, потерянная история запусков agent #2 не пересчитывается.

  • ocr review v0.142.17: фикс medium-замечаний в skills-навигации. По итогам ocr review -c c961ffe исправлены 5 проблем UX/a11y в страницах навигации навыков:

  • Orphan skill_id в URL — при переходе по ?skill_id=N, где навык N отсутствует в списке агента (удалён/нет прав), <select> молча показывал «Все», хотя фильтр был активен и список пуст. Теперь на страницах /skills/runs и /skills/improvements рендерится synthetic <option> с подписью #N (недоступен).

  • Мёртвый guard{#if skillId > 0} на странице редактирования навыка удалён: skillId уже валидируется в onMount, ветка недостижима.

  • Accessibility — навигационным ссылкам «Запуски этого навыка»/«Улучшения этого навыка» добавлены aria-label с именем навыка (раньше скринридер не понимал, к какому навыку относится ссылка).

  • Симметрия reset статуса — при приходе с ?skill_id= статус сбрасывался на «Все», но обратный сброс при очистке skill-фильтра отсутствовал; теперь выбор «Все» в фильтре навыка возвращает filterStatus к дефолтному 'pending'.

  • Неточный комментарий — комментарий про goto({replaceState:true}) в improvements уточнён: goto проходит через навигационный пайплайн SvelteKit (load-функции, page-store), но на этой странице load-функций нет — побочный эффект минимален.

Added

  • Навигация к запускам/улучшениям навыка + URL-фильтр ?skill_id=. Ссылки на страницы «История запусков» и «Улучшения» сделаны заметнее (secondary-кнопки вместо текстовых underline-ссылок), а со страницы редактирования навыка добавлен переход с предустановленным фильтром по конкретному навыку. Страницы /skills/runs и /skills/improvements теперь читают и пишут ?skill_id= в URL — работают share-by-URL и back/forward.

  • Backend (Go): ListSkillImprovementProposals получил опц. параметр skillID *int64 (nil = без фильтра, симметрично с ListSkillRuns — без 0-sentinel). Handler ListProposals парсит ?skill_id= через optInt64Query (400 на невалидное значение). SQLite + PostgreSQL + DualStore обновлены. 2 новых теста: TestSkillProposal_List_SkillIDFilter (store: nil-фильтр + фильтр по ID + комбо status+skill + несуществующий ID) и TestListProposals_FiltersBySkillID (handler: фильтр по skill_id + 400 на abc).

  • Frontend: API-клиент api.skills.listProposals(agentId, status?, skillId?). Страница /skills/improvements: чтение ?skill_id= при mount (со сбросом дефолтного filterStatus='pending''', чтобы показать всю историю навыка), новый <select> фильтра по навыку, синхронизация выбора с URL (goto + replaceState/noScroll/keepFocus). Страница /skills/runs: чтение ?skill_id= + URL-sync в select. Новая утилита web/src/lib/utils/parsePositiveIntParam.ts (строгий парсер query-параметра → number | null, >0 инвариант, Number.isInteger — отсекает Infinity/дробные).

  • UI-навигация: на /skills две текстовые ссылки («Улучшения →»/«История запусков →») заменены secondary-кнопками (bg-panel + border-outline, без стрелок). На странице редактирования навыка /skills/{skillId} добавлена панель «Связанное» с двумя кнопками-ссылками: «Запуски этого навыка» → ?skill_id=, «Улучшения этого навыка» → ?skill_id= (скрыта, если skillId невалиден).

  • ocr review: по итогам трёх прогонов исправлены все medium+high — включая race-bug, найденный ревьюером (loadAgentSkills в improvements делил createCancellableRequest с loadProposals в Promise.all, из-за монотонного next() результат всегда дропался как stale; исправлено через отдельный cancelled-флаг), и нарушение style-guide (!= null!== undefined в endpoints.ts). Low/nit (select a11y-id, new URL(page.url) cloning, back/forward reactivity как project-wide known limitation) оставлены.

  • Frontend-hardening skill-tracking (Этап 5 аудита ocr review): типизация, accessibility, адаптив, race-protection. Семь пунктов фронтенда из docs/fix-audit-problem.md (5.1–5.7) + извлечение composable createCancellableRequest (7.6) + расширение тестов (7.5). Все правки обратно-совместимы, закрывают класс type-safety-багов, a11y-проблем и UX-дрейфа в skills-разделе WebUI; особое внимание — мобильному адаптиву (таблица запусков → card-view на <sm, кап отступов nested-timeline).

  • Типизация (5.1) — создан SkillRunStepStatus = 'running' | 'completed' | 'error' (раньше status: string), типизированы STEP_STATUS_LABEL/COLOR и PROPOSAL_STATUS_LABEL/COLOR (Record<string,string>Record<SkillRunStepStatus,...> / Record<SkillImprovementProposalStatus,...>), SkillRunStep.source: SkillRunStepSource | stringSkillRunStepSource, SkillRunStep.status: stringSkillRunStepStatus. В endpoints.ts:767 listRuns параметр status?: stringstatus?: SkillRunStatus. Создан SkillImprovementAnalysis interface ({analysis?, runs_used?, generated_at?} — согласован с реальным output skill_reflection.go:242-246) и подключён в consumer (parseAnalysis в improvements вместо inline-типа). Устаревший комментарий в Go store.go:661 поправлен: {patterns, duplicates, errors, drift} (никогда не соответствовавший реальности) → {analysis, runs_used, generated_at} со ссылкой на место формирования.

  • Bug 0ms в formatSkillRunDuration (5.2)if (!ms || ms < 0) рендерил валидную длительность 0ms (мгновенный tool-call) как . Заменено на if (!Number.isFinite(ms) || ms < 0) — теперь 0'0мс', NaN/Infinity'—'.

  • Guard O(n·m) в computeLineDiff (5.4) — LCS-DP без лимита мог фризить UI на мегабайтных proposal’ах (аллоцировал матрицу (n+1)×(m+1)). Добавлен DIFF_MAX_CELLS = 2_000_000: при превышении — fallback на side-by-side без выравнивания (все old как removed + все new как added). Метод не меняет поведение для типовых контентов (сотни строк).

  • createCancellableRequest composable (7.6) — новый web/src/lib/utils/cancellableRequest.ts инкапсулирует seq/cancelled-guard, который ранее дублировался вручную в runs/+page.svelte, [runID]/+page.svelte, improvements/+page.svelte. На него переведены все три страницы (заодно добавлен отсутствовавший cancelled-флаг в runs/+page.svelte, где при unmount во время полёта запроса могла сработать goto('/login') на мёртвой странице). Контроль остаётся в call site (goto/toast/error-handling — намеренно, разные страницы реагируют по-разному). 7 Vitest-тестов (stale drop, rapid retries, cancel idempotency, unmount-simulation).

  • Race-protection в [runID]/+page.svelte (5.3) — Bug 2: разделяемый loading разделён на runLoading/stepsLoading (раньше один флаг сбрасывался по loadRun.finally, оставляя steps «недозагруженными»); Bug 3: parseInt(page.params.runID) → regex /^\d+$/ (отсекает malformed URLs «123abc»); Bug 4: breadcrumb #${runId} suppressed пока run не загружен (раньше показывал #NaN). Bug 1 (error-path не gated) помечен в аудите НЕ АКТУАЛЬНО — подтверждено, seq-проверка уже была.

  • Оживление ui/Modal + перевод inline-модалок (5.5.4) — компонент web/src/lib/components/ui/Modal.svelte (с полноценным focus-trap, Esc, role="dialog"/aria-modal, возвратом фокуса) существовал, но нигде не использовался. На него переведены inline-модалки: reject-modal в improvements/+page.svelte (была без role/focus-trap/Esc) и add-modal в skills/+page.svelte (аналогично). Footer add-modal теперь скрывается когда нет доступных навыков (раньше focus-trap ломался на единственном disabled-элементе).

  • Accessibility (5.5.1/5.5.2/5.5.3) — оба toggle (legacy-injection + per-skill enable) получили role="switch"/aria-checked/aria-label + :focus-visible ring (WCAG 2.4.7); per-skill toggle дополнительно disabled/aria-busy на время запроса (in-flight guard через toggleInFlight: Set блокирует flip-flop при двойном клике). Disclosure-кнопки (раскрытие шага в [runID], тоггл diff в improvements) получили aria-expanded/aria-controls; diff-блок теперь всегда в DOM с hidden-toggle (раньше aria-controls ссылался на условно-рендеренный элемент — NVDA+Firefox warning). Skill-badge в ChatComposer получил role="status"/aria-live="polite" + sr-only summary (раньше динамический индикатор активности был невидим скринридеру).

  • saveLegacyInjection(next) + re-sync (5.6) — функция принимает целевое значение аргументом (раньше оптимистичный flip делался до вызова, на ошибку — наивный toggle, рассинхронизирующийся при гонках). На ошибку используется ответ api.agents.update (возвращает полный Agent с .config) вместо доп. GET — убирает race-окно и риск апдейта после размонтирования. Cancellable-guard через saveReq.

  • Сброс модалок в loadProposals() (5.7) — в начале функции сбрасываются confirmApply/confirmReject, чтобы устаревший proposal не «завис» после refresh/смены фильтра. rejectReason намеренно НЕ сбрасывается — иначе пользователь потеряет введённую причину, если модалка была открыта.

  • Мобильный адаптив — таблица истории запусков (6 колонок: Навык/Статус/Цель/Итог/Длительность/Начало) на <sm теперь рендерится как card-view (компактные карточки с теми же данными), на sm+ — прежняя таблица. Счётчики статистики grid-cols-4grid-cols-2 sm:grid-cols-4; avg/median justify-betweenflex-wrap. Шапка skills с ссылками «Улучшения→»/«История→» получила flex-wrap (раньше на узких налезали). Кап отступа nested-timeline (depthIndent) на 4rem — глубокая вложенность больше не вызывает горизонтальный скролл.

  • Тесты (7.5)formatSkillRunDuration(0) теперь '0мс' (было '—'); добавлены edge-cases NaN/Infinity'—'. computeLineDiff large-input (5000×500 строк > DIFF_MAX_CELLS) → fallback side-by-side; малые входы (100×100) продолжают использовать DP-выравнивание. Всего +4 тест-кейса.

  • Concurrency-hardening skill-tracking (Этап 3 аудита ocr review): thread-safety на lifecycle-операциях + optimistic locking. Закрывает класс data race’ов и lost-update-сценариев в skill-tool, SkillRunStepsHook и ApplySkillImprovementProposal, опираясь на DB-level инварианты из миграции 089 (Этап 2). См. план docs/fix-audit-problem.md (Этап 3).

  • SessionActor.getAgentID() (3.1) — новый accessor читает a.agentID под chanMu.RLock(). Раньше idle-timer (actor.go) читал поле напрямую без блокировки, что ловил go test -race (запись в setChannelInfo под chanMu.Lock() из handleInbound-горутины). Альтернатива (immutable поле после NewSessionActor) концептуально лучше, но требует аккуратной правки конструктора — оставлено на будущее.

  • SkillRunStepsHook: lock на всю итерацию for-loop (3.3)OnToolComplete теперь держит h.mu на весь цикл записи шагов, а не отпускает между iteration++ и CreateSkillRunStep. Раньше два конкурирующих OnToolComplete могли прочитать одинаковые iteration/depth до фиксации INSERT’а — теоретическая гонка (сейчас executeTools последователен), но при будущем async/batched tool-execution стала бы реальной. Store-вызов под локом допустим (CreateSkillRunStep короткий); комментарий явно описывает trade-off и batch-alternative.

  • SkillTool.beginMu sync.Mutex (3.4) — сериализует beginAction/endAction для одного (SkillTool, agentID). Защищает от гонки двух параллельных end‘ов, которые через GetActiveSkillRuns + first-match могли закрыть чужой run. Гранулярность — на весь SkillTool (cross-skill begin/end тоже сериализуются) — сознательный компромисс против striped-lock, который усложнил бы код для редкого сценария нескольких активных skills в одном turn’е. lookupEnabled вынесен до мьютекса (read-only, не трогает стек runs) для сокращения критической секции. activate не мьютексится — read-only. Concurrency-комментарий явно документирует контракт с partial unique index 089.

  • ApplySkillImprovementProposal: optimistic locking (3.5)UPDATE skills SET content=?, metadata=?, updated_at=? WHERE id=? AND metadata=? (SQLite/PG симметрично) с проверкой RowsAffected. При 0 возвращается существующий sentinel store.ErrConcurrentModification. Handler ApplyProposal мапит errors.Is(err, store.ErrConcurrentModification) в 409 Conflict (по образцу UpdateAgentIfNotChanged в agent.go:292), proposal остаётся pending. Защищает от сценария: между GetSkillImprovementProposal и UPDATE в транзакции кто-то отредактировал skill через UI (или применил другой proposal) — без guard’а новый APPLY перезатёр бы эти правки.

  • popRunForSkill: slog.Debug → slog.Warn (3.6) — рассинхронизация БД (running-run) и стека hook’а (race/orphan) теперь видна в проде, а не только на debug-уровне. Поведение осталось тем же — no-op.

  • Тесты (5 новых + 1 расширенный): TestSessionActor_GetAgentID_ConcurrentSafe (actor_test.go, под -race); TestSkillRunStepsHook_OnToolComplete_ConcurrentRaceSafe (skill_run_steps_hook_test.go, под -race, 8 goroutine × 25 calls, проверка уникальности iteration); TestSkillTool_BeginEnd_ConcurrentCalls_Serialized + TestSkillTool_BeginEnd_DifferentSkills_ConcurrentRaceSafe (skill_tool_test.go, под -race, same-skill + cross-skill параллельность); TestApplyProposal_OptimisticLocking_GuardRejectsStaleMetadata + TestSkillProposal_Apply_ConcurrentModification_NotFalsePositive (skill_proposals_test.go, SQL-level proof + success-path); TestApplyProposal_ConcurrentModification_Returns409 (skills_test.go, handler-маппинг через декоратор store, переопределяющий только ApplySkillImprovementProposal). Расширение TestSkillRunStepsHook_EndWithoutBegin_NoError + TestSkillRunStepsHook_PopNotInStack_LeavesEmptyStack для 3.6.

  • Hardening схемы skill-tracking (Этап 2 аудита ocr review): DB-level инварианты на (agent, skill)-пары. Миграция 089_skill_tracking_hardening (4 файла SQLite+PG) добавляет 5 индексов и набор CHECK-констрейнтов, которые закрывают TOCTOU-гонки между тиками reflection-job и дубликаты running-ранов на уровне базы — теперь это DB-guarantee, на которое опирается идемпотентность MarkOrphanedSkillRuns и runner-логика, а не только per-agent mutex в коде. См. план docs/fix-audit-problem.md (Этап 2).

  • Миграции (SQLite + PostgreSQL, up/down): partial unique index uniq_skill_runs_running ON skill_runs(agent_id, skill_id) WHERE status='running' (запрещает два параллельных running-рана); uniq_skill_proposals_pending ON skill_improvement_proposals(agent_id, skill_id) WHERE status='pending' (один pending proposal — защита от TOCTOU между двумя тиками reflection-job, между которыми проходит LLM-вызов длительностью в секунды); idx_skill_runs_status_running ON skill_runs(status, started_at DESC) WHERE status='running' (cross-agent sweep в AgentLoop.Stop()); idx_skill_run_steps_source_ref ON skill_run_steps(source, source_ref_id) WHERE source_ref_id IS NOT NULL (forward-compat для аналитики, partial для компактности). CHECK-констрейнты на leaf-таблицах: skill_run_steps.status IN ('','running','completed','error','waiting') ('waiting' добавлен по итогам ocr review — runner.go:427 пишет его для ask_user tool-call; без него CHECK сломал бы запись шага) + source IN ('live','tool_audit','message'); skill_improvement_proposalschk_skill_proposals_resolved (pending ⇔ resolved_at IS NULL; applied/rejected ⇔ resolved_at IS NOT NULL) + chk_skill_proposals_size (256 KiB, defense-in-depth с app-layer maxProposedContentBytes). В PG добавлены также chk_skill_runs_summary_size (≤4000 bytes) / chk_skill_runs_reflection_size (≤8000 bytes) — выровнены с Go-константами maxSummaryLen/maxReflectionLen (skill_tool.go:176-177) и JSON Schema maxLength; в SQLite size-CHECK на skill_runs сознательно не добавлен из-за риска пересоздания таблицы со входящим FK из skill_run_steps (прецедент 066/069→070). size-CHECK’и в PG идут через NOT VALID + VALIDATE CONSTRAINT, чтобы ADD взяло только catalog-lock, а валидация — SHARE UPDATE EXCLUSIVE (не блокирует reads/writes на больших прод-таблицах). SQLite-миграция оборачивает table-recreation в PRAGMA foreign_keys=OFF/ON и содержит post-check инструкцию для верификации отсутствия *_old-таблиц после наката.

  • Backend (Go): новый store.IsUniqueViolationError(err) в internal/store/errors.go — кросс-бэкенд классификатор UNIQUE-violation (PG SQLSTATE 23505 через *pgconn.PgError + SQLite SQLITE_CONSTRAINT_UNIQUE = 2067 через *sqlite.Error, без substring-fallback, который давал бы false positives на user-controlled данных). SkillReflectionJob.reflectSkill (internal/agent/skill_reflection.go) при UNIQUE-violation от CreateSkillImprovementProposal теперь возвращает (0, nil) с debug-логом вместо warn-уровня с ошибкой — конкурентный тик после миграции 089 становится ожидаемой race-ситуацией (pending-proposal уже создан), а не источником ложных alert’ов в прод-логах. HasPendingSkillImprovementProposal остаётся как fast-path (чтобы не тратить LLM-токены), финальный гарантия — DB.

  • Тесты: 9 новых store-тестов инвариантов в internal/store/skill_tracking_hardening_test.go (partial-unique runs reject-duplicate + allow-after-finish + allow-for-different-agents; partial-unique proposals reject-duplicate + allow-after-apply; CHECK на step status/source enum; resolved-invariant; size-cap; IsUniqueViolationError для PG-кодов). 1 новый agent-тест race-tolerance в internal/agent/skill_reflection_test.go (TestSkillReflection_ToleratesConcurrentProposalCreation через store-wrapper с TOCTOU-симуляцией). 1 новый store-тест internal/store/errors_test.go (real SQLite UNIQUE-violation распознаётся). 6 существующих тестов адаптированы под новые DB-inварианты (дубли running/pending заменены на разные skills/agents либо finish/apply между create). TestSQLiteMigrationsUpDownUp покрывает накат/откат 089.

Changed

  • ocr review backlog: закрытие всех остаточных замечаний #170 (6 этапов v0.142.11–v0.142.16). После перевода модалок cron/heartbeat в страницы (#170) остался backlog из ~36 замечаний ocr review. Все закрыты единообразно 6 PATCH-этапами; ~35 из 36 — закрыты, 1 (per-field error mapping, D-774) осознанно отложен как минор (требует backend-контракта structured field errors, не оправдано для форм из 2-7 полей) и задокументирован в docs/todo.md.

  • Этап 1 — Backend GET-by-id (v0.142.11): новые эндпоинты GET /api/v1/agents/{id}/cron/{jobID} и GET /api/v1/agents/{id}/heartbeat/{taskID} (IDOR-safe через requireCronInAgent/requireHeartbeatInAgent, 404 на чужой ресурс — по образцу SkillsHandler.GetRun). api.cron.get/api.heartbeat.get в endpoints.ts. 8 handler-тестов (TestCronGet_*, TestHeartbeatGet_*).

  • Этап 2 — Edit-страницы: race-guard + GET-by-id + strict route-param (v0.142.12): 4 edit-страницы переведены с list+find на новые GET-by-id эндпоинты; защищены от race через createCancellableRequest (канон проекта, как в skills runs/[runID]). Новая утилита web/src/lib/utils/parseRouteId.ts — regex /^\d+$/ + Number.isInteger + >0 (NaN для malformed '123abc'/'1.5'/'-1', раньше parseInt молча обрезал). Unauthorizedgoto('/login').

  • Этап 3 — List-страницы: afterNavigate + error-гигиена (v0.142.13): afterNavigate на 5 списках (cron/heartbeat × agent/settings + skills) перечитывает данные при возврате с create/edit-страниц (onMount не перезапускается при client-side навигации туда-обратно). settings/cron/settings/heartbeat: error='' в начале toggle/delete (баннер больше не висит от прошлой неудачи); пустой catch {} в loadAgentCron/loadAgentHeartbeat заменён на логирование; последовательный forPromise.all (N+1 fix).

  • Этап 4 — Редакторы: ограничения ввода и a11y (v0.142.14): maxlength + счётчик на textarea (name 200, message 10000 cron, title 200, description 4000 heartbeat — мегабайтный paste больше не уходит целиком). model_id select → sentinel '__default__' вместо '' (корректная обработка id=0, явное намерение). schedule_tz: <datalist> с COMMON_TIMEZONES (подсказка IANA-зон). role="alert" на всех error-баннерах (a11y, скринридеры). handleSave во всех 8 страницах: try/catch + toast.success + throw friendlyError (403/404 → человекочитаемое сообщение). Новая утилита web/src/lib/utils/friendlyError.ts.

  • Этап 5 — cronSchedule корректность русского (v0.142.15): describeEvery — upfront-нормализация секунд (ветка 's' больше не мёртвая: 45s → «каждые 45 секунд», а не «каждые 1 минуту»); gendered per-unit prefix (каждую минуту ж.р. vs каждый час м.р.); единый pluralizeRu(n, one, few, many) + pluralizeIndex(n). Для 21/31 (mod10===1) prefix и форма слова выбираются по индексу формы, а не по v===1 — «каждый 21 час»/«каждую 21 минуту» теперь корректно. 22 unit-теста (cronSchedule.test.ts: seconds/минуты/часы/дни/cron-пресеты/pluralizeRu edge-cases 1-2-5-11-21-101-111).

  • Этап 6 — DRY (v0.142.16): новый компонент web/src/lib/components/AgentContextHeader.svelte — извлечение дублированного блока «Агент: …» из 4 settings-страниц. Замечания 443 (hover-style), 1063 (button/<a> mix), 1241 (description optional), 1054 (route-shape ?agent=) оставлены после анализа как корректные/косметика, задокументированы в docs/todo.md.

  • Проверки: go test ./internal/... зелёный, golangci-lint 0 issues, npm run check 0 errors, npm test 410 passed (+22 новых cronSchedule-тестов).

  • Cron/Heartbeat: модалки создания/редактирования → отдельные страницы. Inline-модальные окна в разделах /agents/[id]/cron, /settings/cron, /agents/[id]/heartbeat, /settings/heartbeat заменены полноценными страницами /new и /[id] по образцу навыков (SkillEditor + тонкие страницы-обёртки). Причина: в модальном окне неудобно редактировать длинные описания задач/инструкции для агента. Все 4 раздела приведены к единому паттерну: переиспользуемый компонент-форма + тонкая страница (загрузка в onMount, breadcrumbs через $derived.by, навигация через goto после сохранения).

  • Новые компоненты: web/src/lib/components/CronJobEditor.svelte (поля: название, тип расписания at/every/cron, расписание с пресетами + datetime-local для at, часовой пояс для cron, сообщение/инструкция textarea rows=8, чекбокс «Доставить результат», модель) и web/src/lib/components/HeartbeatTaskEditor.svelte (название + описание/инструкция textarea rows=8). По образцу SkillEditor.svelte.

  • Новая утилита web/src/lib/utils/cronSchedule.ts — пресеты cronPresets/everyPresets и функции describeCron/describeEvery/describeSchedule, ранее дублировавшиеся inline в settings/cron/+page.svelte, вынесены и переиспользуются компонентом и обоими списками. cronDescriptionMap теперь частично деривится из cronPresets (устранён рассинхрон пресетов и описаний: «Каждое воскресенье в 12:00»/«1-е число месяца в 09:00» теперь описываются корректно). Для неизвестных cron-выражений возвращается «Пользовательское расписание (…)» вместо сырого выражения.

  • 8 новых страниц: /agents/[id]/cron/{new,[jobId]}, /settings/cron/{new,[agentId]/[jobId]}, /agents/[id]/heartbeat/{new,[taskId]}, /settings/heartbeat/{new,[agentId]/[taskId]}. Страницы настроек для создания читают ?agent= через page.url.searchParams. Все с хлебными крошками, fallback на «Агент» при ошибке загрузки, валидацией ID (<= 0).

  • Унификация: в /settings/cron добавлен выбор модели (раньше отсутствовал — расхождение со страницей агента).

  • Списки переведены на навигацию ссылками (<a href>): кнопки «Добавить»/«Изменить» стали ссылками на новые страницы, inline-модалки и весь связанный state (showModal, editingJob, cronForm, save, backdrop-хендлеры) удалены. Toggle/удаление остались в списках через ConfirmModal. Заодно исправлен дублированный класс text-warning-text text-warning-text в settings/heartbeat.

  • Типизация: в сигнатуру api.cron.create (endpoints.ts) добавлено поле model_id?: number | null (раньше тип объявлял только provider_id, но фронтенд отправлял model_id → potential excess-property error).

  • Performance-hardening skill-tracking (Этап 4 аудита ocr review): устранение N+1, prompt bloat, неограниченных выборок. Шесть пунктов производительности из docs/fix-audit-problem.md (4.1–4.6). Все правки обратно-совместимы, изменяют только hot-path’и; фиксы опираются на уже применённую миграцию 089 и существующие конвенции кодовой базы.

  • N+1 в buildReflectionPrompt (4.1) — новый batched accessor Store.ListSkillRunStepsByRunIDs(ctx, runIDs) (map[int64][]*SkillRunStep, error) (SQLite через IN (?,?,...) + strings.Repeat-паттерн, PostgreSQL через ANY($1::bigint[]), делегирование в DualStore) заменяет поэлементный ListSkillRunSteps в цикле по runs. Сигнатура buildReflectionPrompt расширена ctx context.Context (раньше использовался context.Background(), что не пробрасывало tick-deadline). Graceful-fallback: при ошибке batched-fetch reflection продолжается без шагов (warn-лог), не валит tick. Документирован контракт: пустой runIDs → пустая non-nil map без обращения к БД; runs без шагов не появляются ключами.

  • LoadSkillsConfig на каждый turn без кэша (4.2) — сигнатура изменена на LoadSkillsConfig(ctx, s, agentID), внутренний context.Background() заменён на передаваемый ctx. Добавлено поле cachedSkillsConfig *SkillsConfig в ContextBuilder (сбрасывается в buildSystemPromptWithBudget рядом с cachedAgent) — на каждый turn один GetAgent + JSON-unmarshal вместо N. Все 1 production + 9 тестовых call sites обновлены. В godoc ContextBuilder добавлено предупреждение о небезопасности для concurrent-use.

  • GetSkillRunStats — полный рефакторинг (4.3) — старая реализация грузила все skill_runs строки в память и делала двойной pass (sort копии + sum оригинала) с неограниченным ростом durations. Новый код: (1) агрегатный SQL-запрос одним round-trip’ом через COUNT(*) / SUM(CASE WHEN ...) / COUNT(*) FILTER (WHERE ...) / AVG (SQLite + PG симметрично, диалект-адаптация); (2) отдельный запрос для медианы за окно skillRunStatsLookbackDays = 90 дней, capped skillRunStatsMedianCap = 1000 значений на скилл (in-place sort.Ints, без копии); (3) GROUP BY skill_id + MIN(skill_name) — устойчив к дрейфу skill_name в skill_runs при rename скилла; (4) avg и median считаются по одному и тому же статус-фильтру ('completed'/'failed'/'orphaned') и одному cutoff — метрики сопоставимы; (5) named helper’ы (loadAggregates / loadDurations) возвращают error явно (раньше IIFE молча проглатывала DB-ошибки, маскируя сбои под “нет данных”). Хелпер medianSorted сохранён (единая логика медианы для обоих стораджей).

  • Prompt bloat в buildReflectionPrompt (4.4)sk.Content, r.Goal, r.Summary, r.Reflection раньше писались в промпт без лимита; при TopNRuns=10 и многословных полях промпт уходил в мегабайты. Добавлены константы maxSkillContentInPrompt = 8 KiB и maxRunFieldLen = 1024 (рядом с переиспользуемым truncateForPrompt); все unbounded-поля обёрнуты в truncateForPrompt.

  • Остаточные Summaries()Has (4.5)ToolSummaryProvider.Has (добавлен в Этапе 0.2) теперь используется в buildToolHintsBlock (memory_search), buildScratchpadGuidance (scratchpad_write), buildSubAgentGuidance (spawn) вместо прежней итерации Summaries() ради membership-проверки одного имени (аллокация map каждый вызов). Единственное легитимное использование Summaries() осталось в buildToolsBlock — там реально нужен список всех имён.

  • lookupEnabled → один JOIN (4.6) — новый метод Store.GetEnabledSkillForAgent(ctx, agentID, name) (*Skill, error) (SQLite/PG/DualStore, JOIN skills ⨝ agent_skills с WHERE agent_id=? AND name=? AND enabled) заменяет прежние два round-trip’а (GetSkillByName + ListEnabledSkillsForAgent + client-side linear scan) в SkillTool.lookupEnabled. Добавлен sentinel store.ErrSkillNotEnabled (покрывает все случаи отсутствия row: не существует / не подключён / disabled — осознанное объединение, downstream различает через errors.Is). Пользовательское сообщение объединено: "skill %q not found or not enabled for this agent" — это упрощает код и убирает fallback-query (которая компрометировала бы оптимизацию на error-path). Godoc явно документирует объединение и путь для различения. Stale-comment в beginAction обновлён.

  • Тесты (DoD этапа 7): TestListSkillRunStepsByRunIDs_EmptyInput + TestListSkillRunStepsByRunIDs_GroupsByRunID (store, покрытие batched-accessor: пустой список, группировка, порядок, contract run-без-шагов и unknown-runID); TestGetEnabledSkillForAgent (store, 4 сценария: Enabled / Disabled / Unknown / WrongAgent — все возвращают корректный sentinel); TestReflectSkill_NPlus1ReturnsGraceful (agent, mock-store с ошибкой ListSkillRunStepsByRunIDs — reflection продолжает работу); TestBuildReflectionPrompt_TruncatesUnboundedFields (agent, oversized-поля → bounded-промпт). Существующие тесты TestSkillRun_Stats* (3 кейса) и TestSkillTool_Begin_NotFound/NotEnabled/Activate_NotEnabled (3 кейса) продолжают проходить без правок. go test -race ./internal/agent/... зелёный.

  • SkillTool: lookupEnabled теперь вызывается до мьютекса в beginAction/endAction (read-only store-вызовы не требуют блокировки) — сокращает критическую секцию beginMu.

  • Style/telemetry-hardening skill-tracking (Этап 6 аудита ocr review): типизированные sentinel’ы, SemVer, json.Decoder, drift guard, логирование. Девять пунктов код-стайла и телеметрии из docs/fix-audit-problem.md (6.1–6.9) + релевантные тесты из Этапа 7. Все правки обратно-совместимы, улучшают maintainability и observability без изменения runtime-поведения (кроме incrementSkillVersion для pre-release и parseReflectionResponse для brace-in-string — оба fixing latent bugs).

  • Sentinel ErrProposalNotPending (6.2) — хрупкий strings.Contains(err.Error(), "is not pending") в handler.ApplyProposal/RejectProposal заменён на errors.Is(err, store.ErrProposalNotPending). Store (SQLite/PG симметрично) оборачивает not-pending/not-found ошибки через %w к новому sentinel’у. Тесты TestSkillProposal_Apply_NotPending/Reject_NotPending/Reject_NotFound усилены до errors.Is.

  • lookupEnabledForAgent + sentinel propagation (6.1) — rename lookupEnabledlookupEnabledForAgent (симметрично ListEnabledSkillsForAgent/GetEnabledSkillForAgent). User input (имя скилла) больше не попадает в сообщение об ошибке — sentinel store.ErrSkillNotEnabled пробрасывается напрямую (validateSkillName отсекает некорректные значения до store). Тесты TestSkillTool_Begin_NotFound/NotEnabled усилены errors.Is(err, store.ErrSkillNotEnabled).

  • incrementSkillVersion через golang.org/x/mod/semver (6.5) — переписана с использованием semver.IsValid/MajorMinor/Prerelease/Build для корректной обработки pre-release (1.0.0-rc.11.1.0-rc.1) и build-metadata. Невалидный SemVer → lenient-fallback (сохраняет прежнее поведение для 1.0.abc/5), полностью непарсируемый → timestamp-суффикс (уникальность вместо потенциальной коллизии). Поведение для """1.0.0" и v/V-prefix сохранено.

  • parseReflectionResponse через json.NewDecoder (6.8) — ручной brace-balancing ломался, если proposed_content содержал } внутри строки (например code block return {}). Заменён на json.Decoder.Decode — стандартная библиотека корректно пропускает строковые литералы. Новый тест TestParseReflectionResponse_JsonWithBraceInString.

  • skillMeta drift guard (6.3) — compile-time assertion (type conversion skillMetaskills.SkillMetadata) + round-trip тест TestSkillMetadataRoundTrip (encode → parse → DeepEqual) в новом файле internal/store/skill_metadata_test.go. Если каноническая структура получит поле, а mirror — нет, assertion не скомпилируется.

  • slog.Warn вместо silent-swallow (6.4)parseMetadataRaw и encodeMetadataRaw логируют corrupt-JSON / marshal-failure на Warn (раньше молча возвращали zero/"{}", затирая плохой blob без следа). Raw blob обрезан до 256-байтного preview (future-proofing против exfiltration).

  • Локализация комментариев (6.6) — русские inline-комментарии в actor.go (idle-timer), helpers.go (requireSkillRunInAgent/requireSkillImprovementProposalInAgent/optInt64Query) и skill_metadata.go переведены на английский; удалён китайский символ 致命 и опечатка показовет.

  • Логирование (6.7)handler.ApplyProposal: логируется конкретная версия (skills.ParseMetadata(sk.Metadata).Version) вместо всего metadata-JSON blob’а. context.go:buildSkillsBlock: в slog.Debug("skills injection") добавлен session_key (для корреляции с skill_runs/skill_run_steps); вызов ContextWithSessionKey в actor.go перенесён до BuildMessages (раньше ctx не имел session key в момент сборки промпта → поле всегда было пустым).

  • store.IsNotFoundError + фильтр ErrNoRows в handler — новый классификатор (по образцу IsUniqueViolationError) распознаёт sql.ErrNoRows и pgx.ErrNoRows. requireSkillImprovementProposalInAgent логирует только genuine transient DB-ошибки (не каждую легитимную 404).

  • Документация (6.9) — ADR 2026-06-17-skill-activation-tracking.md обновлён: статус Accepted (hardened), добавлена секция о hardening (этапы 0–6). docs/roadmap.md — секция v0.142.x — Skill Activation Tracking Hardening. docs/features-matrix.md — подраздел 14.1. Skill Activation Tracking со статусами.

Fixed

  • Cron: выбор модели (model_id) не работал — поле игнорировалось при сохранении и исполнении. Баг был двухслойным, хотя store-слой полностью поддерживал колонку model_id (SQLite/PG: INSERT/UPDATE/SELECT). (1) HTTP-хендлер CronHandler.Create/Update (internal/server/handler/cron.go) декодировали только provider_id, поле model_id отсутствовало в request-структуре → фронтенд отправлял model_id, но он молча отбрасывался. (2) При исполнении коллбэк onJob (cmd/taigaclaw/main.go) публиковал InboundMessage, а SessionActor.handleInbound выбирал провайдер через ResolveProvider(agentID), который читал только agent.DefaultModelID — ни job.ModelID, ни job.ProviderID не применялись, задача всегда шла на дефолтную модель агента.

  • Handler: в request-структуры Create/Update добавлено поле model_id; значение применяется к job.ModelID. Для Update использован json.RawMessage, чтобы различать три состояния: ключ отсутствует → не трогать; null → очистить (вернуть на дефолт); число → установить (паттерн **int64 не позволяет отличить null от отсутствия, поэтому provider_id имеет ту же историческую ограниченность — не расширял скоуп).

  • AgentLoop: новый метод ResolveProviderForModel(ctx, agentID, modelID *int64) — при ненулевом override переиспользует существующий resolveFromModel (сам тянет провайдера по model.ProviderID), при ошибке/отсутствии/disabled-model gracefully fallback’ит на ResolveProvider (дефолт агента), чтобы turn не падал.

  • Bus: новый метод InboundMessage.ModelIDOverride() читает Metadata["_model_id_override"] (по аналогии с _wants_stream/_session_title), типобезопасно парсит int64/int/float64 (JSON round-trip), отвергает 0/негативные/неверный тип.

  • Actor: handleInbound один раз читает override из сообщения и использует ResolveProviderForModel в обеих точках выбора провайдера (стр. 388 — основной turn, 586 — ContextBuilder).

  • main.go: cron-коллбэк кладёт job.ModelID в metadata под _model_id_override (только когда не nil и > 0); heartbeat не затронут.

  • Тесты: internal/server/handler/cron_test.go (Create с model_id → персистентность + re-read из store; Create без → nil; Update устанавливает; Update очищает через null); internal/bus/bus_test.goModelIDOverride() (8 случаев: nil/missing/int64/int/float64/zero/negative/wrong-type); internal/agent/loop_model_override_test.goResolveProviderForModel (nil/zero → дефолт; unknown model → fallback на глобальный, не nil). go test ./internal/... зелёный; race-чистый на новых тестах; golangci-lint — 0 issues.

  • Таймзон-баг в редакторе cron-задач (normalizeForEdit, ). При редактировании cron-задачи с schedule_kind='at' значение schedule_expr (абсолютный момент, UTC) форматировалось через локальные методы Date#getFullYear/getHours/... браузера и отправлялось обратно как наивное datetime-local значение, которое бэкенд трактует как UTC — время срабатывания молчаливо смещалось на offset часового пояса браузера. Исправлено: форматирование через UTC-методы (getUTCFullYear/…), абсолютный момент сохраняется end-to-end. Действовало в обоих новых страницах редактирования (agents/[id]/cron/[jobId], settings/cron/[agentId]/[jobId]).

  • **Сброс schedule_expr при смене типа расписания ** в CronJobEditor: раньше при переключении atevery/cron в поле оставалось значение, невалидное для нового типа (например, datetime в поле cron-выражения), что приводило к silent mis-scheduling. Теперь поле сбрасывается через $effect.

  • **Валидация ID **: страницы создания/редактирования cron/heartbeat теперь отвергают неположительные ID (agentId <= 0, jobId <= 0, taskId <= 0), раньше parseInt('-1') проходил проверку !id || isNaN(id) и уходил на бэкенд.

  • Data race на SessionActor.agentID в idle-timer (3.1)go test -race падал на параллельном чтении a.agentID (без блокировки) и записи в setChannelInfo (под chanMu.Lock()). Чтение переведено на новый accessor getAgentID().

  • Race в SkillRunStepsHook.OnToolComplete (3.3) — между iteration++ и CreateSkillRunStep лок отпускался, что создавало теоретическую гонку на ar.iteration (два конкурирующих OnToolComplete могли записать дублирующий iteration). Лок теперь держится на всю итерацию for-loop.

  • Lost update в ApplySkillImprovementProposal (3.5)UPDATE skills без version guard терял апдейт при конкурентной правке skill.metadata. Добавлен optimistic locking через WHERE id=? AND metadata=? + ErrConcurrentModification (handler → 409 Conflict).

  • Саморефлексия скиллов (итерация 5): cron-job + LLM-анализ последних запусков + proposals с diff. Фоновый SkillReflectionService периодически (по умолчанию каждые 24 ч, opt-in через setting skill_reflection_enabled) для каждого скилла агента с ≥10 завершёнными runs за последние 30 дней собирает (skill_runs + skill_run_steps + reflection), отправляет LLM промпт-анализ (паттерны дубликатов, откаты, ошибки, длинные chains, drift skill_content_hash) и сохраняет улучшенный контент как pending proposal. Пользователь видит страницу /agents/{id}/skills/improvements со списком proposals, построчным LCS-diff (current vs proposed), ссылками на related runs, и кнопками Apply / Reject (с textarea для reason). Закрывает use-case 3 ADR поверх готового доменного слоя из итерации 3. См. ADR docs/adr/2026-06-17-skill-activation-tracking.md (раздел «Forward-compatibility» → «3. Саморефлексия»).

  • Backend (Go): миграция 088_skill_improvement_proposals (4 файла SQLite+PG, с chk_skill_proposals_status enum-constraint, reject_reason полем). Тип SkillImprovementProposal + 6 методов интерфейса Store (CreateSkillImprovementProposal, GetSkillImprovementProposal, ListSkillImprovementProposals, HasPendingSkillImprovementProposal, ApplySkillImprovementProposal (atomic tx: UpdateSkill.Content + инкремент SkillMetadata.Version через локальный skill_metadata.go helper incrementSkillVersion + mark proposal applied), RejectSkillImprovementProposal (с reason, guard status='pending')); реализации идентичны для SQLite/PostgreSQL. Новый SkillReflectionJob + SkillReflectionService (internal/agent/skill_reflection.go): jitter ±20%, gate-at-call-site (по образцу AutoCompactService), tolerance к markdown-fences и trailing-text в LLM-ответе (parseReflectionResponse), hard cap 256 KiB на proposed_content (защита от runaway-генерации). 4 handler’а (ListProposals, GetProposal, ApplyProposal, RejectProposal) в SkillsHandler с IDOR-защитой; ApplyProposal/RejectProposal обёрнуты в RequireAgentAdmin (по образцу approvals/{id}/resolve); TOCTOU race → 409 (не 500); JSON-decode-error в RejectProposal → 400. Роуты внутри RequireAgentRole("agent_user"). Подключение в cmd/taigaclaw/main.go через setting skill_reflection_enabled; shutdown в gracefulShutdown.

  • Frontend (Svelte 5): тип SkillImprovementProposal/SkillImprovementProposalStatus (web/src/lib/api/types.ts); методы api.skills.listProposals(agentId, status?)/getProposal/applyProposal/rejectProposal(agentId, proposalId, reason?) (web/src/lib/api/endpoints.ts, query через URLSearchParams). Утилиты skillProposalStatusLabel/Color и computeLineDiff (LCS dynamic programming) с типом DiffLine в web/src/lib/utils/skillRuns.ts. Новая страница /agents/{id}/skills/improvements/+page.svelte: race-protected loaders (sequence-номер + cancelled-flag через onDestroy), фильтр по статусу (pending по умолчанию), карточки proposals с раскрываемым diff (color-coded added/removed/context), ссылками на related runs (based_on_run_ids), модалки Apply (ConfirmModal) и Reject (textarea). Ссылка «Улучшения →» добавлена на /agents/{id}/skills.

  • Тесты: store-тесты (14 кейсов в internal/store/skill_proposals_test.go — create/get/get-not-found/list-filters-by-status/list-order-newest-first/list-empty/has-pending/has-pending-false-after-apply/apply/apply-not-pending/apply-increments-version-from-empty/reject/reject-not-pending/reject-not-found + 4 unit-теста для incrementSkillVersion/parseMetadataRaw/encodeMetadataRaw); reflection-тесты (12 кейсов в internal/agent/skill_reflection_test.go — creates-proposal/skips-builtin/skips-low-runs/skips-when-pending-exists/skips-same-content/nil-provider-noop/filter-by-lookback/reflect-all/parse-response-ok/markdown-fences/trailing-text/empty/no-json/unbalanced + service disabled-no-op/stop-idempotent); handler-тесты (16 кейсов в internal/server/handler/skills_test.go — ListProposals empty/ok/filters-by-status/invalid-status/invalid-agent-id, GetProposal ok/not-found/foreign-agent-404/invalid-agent-id/invalid-proposal-id, ApplyProposal ok/not-pending-409/foreign-agent-404/invalid-agent-id/oversized-content-422, RejectProposal ok/no-reason-ok/not-pending-409/foreign-agent-404/invalid-agent-id/malformed-json-400). Фронтенд-тесты утилит (11 кейсов в web/src/lib/utils/skillRuns.test.ts для proposal-status-хелперов и computeLineDiff). Всего +57 новых тестов.

  • Flow-просмотр запуска скилла (итерация 4): timeline шагов одного skill_run. Клик по строке в истории /agents/{id}/skills/runs открывает страницу /agents/{id}/skills/runs/{runID} с заголовком run (skill_name, status, goal, summary, reflection, started/finished, duration, skill_version) и timeline всех skill_run_steps — tool, args_summary/result_summary (с раскрытием по клику), status-badge, took_ms, iteration, depth (nested-визуализация через отступ + badge «depth: N»), source. Закрывает use-case 2 ADR и использует готовый доменный слой из итерации 3 без schema-правок. См. ADR docs/adr/2026-06-17-skill-activation-tracking.md (раздел «Forward-compatibility» → «2. Flow-просмотр запуска»).

  • Backend (Go): два handler-метода в SkillsHandler (internal/server/handler/skills.go) — GetRun (для шапки страницы) и ListRunSteps (timeline). Оба используют зарезервированный в итерации 3 requireSkillRunInAgent для IDOR-защиты (404 при чужом run, не 403 — чтобы не палить существование). Роуты GET /skills/runs/{runID} и GET /skills/runs/{runID}/steps внутри RequireAgentRole("agent_user"). Снят //nolint:unused с requireSkillRunInAgent.

  • Frontend (Svelte 5): тип SkillRunStep/SkillRunStepSource (web/src/lib/api/types.ts); методы api.skills.getRun(agentId, runId)/listRunSteps(agentId, runId) (web/src/lib/api/endpoints.ts). Новая страница runs/[runID]/+page.svelte: race-protected loaders (oneSeq/stepsSeq + cancelled-flag через onDestroy), разделение критичной/некритичной ошибок (run vs steps — последняя рендерится баннером с кнопкой «Повторить» над timeline), expandable-шаги, кнопка «Скопировать JSON» (run+steps в буфер). Step-status-хелперы skillRunStepStatusLabel/Color централизованы в web/src/lib/utils/skillRuns.ts (отдельный enum running|completed|error, fallback «неизвестно: X»/bg-dimmed text-label для будущих статусов). Строки таблицы в runs/+page.svelte стали кликабельными с keyboard-поддержкой (role="link"/tabindex="0"/onkeydown Enter/Space).

  • Тесты: 9 handler-тестов в internal/server/handler/skills_test.go (TestGetRun_{OK, NotFound, ForeignAgent_Returns404, InvalidAgentID, InvalidRunID}, TestListRunSteps_{OK, Empty, RunNotFound_Returns404, ForeignAgent_Returns404, InvalidAgentID, InvalidRunID}) с общим fixture seedRunWithSteps; 4 новых unit-теста для step-хелперов в web/src/lib/utils/skillRuns.test.ts. Всего +13 новых тестов.

  • Персистентность запусков скиллов + история + счётчики (итерация 3): доменный слой skill_runs/skill_run_steps + API + UI истории. Каждый begin/end работы над скиллом теперь фиксируется в БД (раньше — только эфемерный live-бейдж и tool_audit_log). Пользователь видит страницу /agents/{id}/skills/runs со счётчиками по скиллам (total/completed/failed/cancelled/orphaned, avg/median duration, прогресс ratio) и таблицей истории запусков с фильтрами и пагинацией. Закрывает use-case 1 ADR и закладывает фундамент под flow-просмотр (итерация 4) и саморефлексию (итерация 5) без будущих schema-правок. См. ADR docs/adr/2026-06-17-skill-activation-tracking.md (раздел «Архитектура» → «Таблица skill_runs»/«skill_run_steps»/«SkillRunStepsHook»).

  • Backend (Go): миграции 086_skill_runs + 087_skill_run_steps (8 файлов SQLite+PG, с forward-compat полями skill_version/skill_content_hash/reflection/tokens_used/parent_run_id/depth/iteration/source, ON DELETE CASCADE/SET NULL). Типы SkillRun/SkillRunStep/SkillRunStat + 10 методов интерфейса Store (CreateSkillRun, FinishSkillRun, GetActiveSkillRuns, GetSkillRun, ListSkillRuns, MarkOrphanedSkillRuns, MarkAllOrphanedSkillRuns, GetSkillRunStats, CreateSkillRunStep, ListSkillRunSteps); реализации идентичны для SQLite и PostgreSQL (медиана длительности считается в Go — единой логикой, т.к. в кодовой базе нет SQL-медианы). Tool skill (skill_tool.go) расширен записью lifecycle: beginActionCreateSkillRun (возвращает run_id), endAction → поиск top-most running-run через GetActiveSkillRuns + FinishSkillRun (fail-safe: сбой трекинга не валит turn LLM). Новый SkillRunStepsHook (internal/agent/skill_run_steps_hook.go, 3-й hook в CompositeHook) держит стек активных runs и атрибутирует к ним шаги других tool-call’ов (nested-runs → шаг пишется во все активные runs стека с depth=позиция, sensitive-args маскируются через переиспользуемый maskSensitiveArgs). Orphan-recovery в двух точках: idle-timer SessionActor.run() (per-agent) + AgentLoop.Stop() (sweep всех оставшихся running при shutdown процесса). Хендлеры GET /api/v1/agents/{id}/skills/runs (фильтры skill_id/status, пагинация) и GET /api/v1/agents/{id}/skills/runs/stats в SkillsHandler; роуты — внутри RequireAgentRole("agent_user") (минимум для чтения агентом-членом). IDOR-хелпер requireSkillRunInAgent зарезервирован для итерации 4 (flow-просмотр).

  • Frontend (Svelte 5): типы SkillRun/SkillRunStat/SkillRunStatus (web/src/lib/api/types.ts); методы api.skills.listRuns(agentId, filters)/runStats(agentId) (web/src/lib/api/endpoints.ts, query через URLSearchParams); утилиты skillRunStatusLabel/skillRunStatusColor/formatSkillRunDuration (мс/с/мин/ч)/ratioCompletedOrphaned в web/src/lib/utils/skillRuns.ts. Страница /agents/{id}/skills/runs/+page.svelte: race-protected loader (sequence-номер, как в llm-log), блок статистики (карточки по скиллам с прогресс-полосой ratio completed), фильтры (<select> по скиллу/статусу), таблица runs, пагинация prev/next. Ссылка «История запусков →» добавлена на /agents/{id}/skills.

  • Тесты: store-тесты (12 кейсов в internal/store/skill_runs_test.go — create/finish/get-active/list-filters/mark-orphaned single+all/stats aggregation+empty+multi-skills/step create+list+cascade/sensitive-args/medianSorted); hook-тесты (11 кейсов в internal/agent/skill_run_steps_hook_test.go — begin/end push/pop, step-writing, nested-writes-to-all-stack, out-of-order nested, end-without-begin, activate-no-run, sensitive-args-masked, unknown-action, iteration-increment, parseSkillArgs); handler-тесты (7 кейсов в internal/server/handler/skills_test.go — ListRuns empty/with-runs/by-status/by-skill-id/invalid-agent-id, RunStats aggregation/empty/invalid). Фронтенд-тесты утилит (17 кейсов в web/src/lib/utils/skillRuns.test.ts). Всего +47 новых тестов.

  • Упрощение discovery скиллов (итерация 2): feature-flag skills.legacy_injectionbuildSkillsBlock теперь разделяет поведение по флагу skills.legacy_injection в agent.Config. При true (дефолт) сохраняется прежнее поведение: скиллы с Always=true и совпавшие по триггерам целиком инжектируются в системный промпт. При false все скиллы показываются LLM только каталогом (name + description) в секции «Available Skills» — полный контент LLM подтягивает сама через tool skill (action=activate). Экономит токены (kilobytes контента на каждый скилл), даёт явный сигнал телеметрии «LLM решила загрузить этот скилл» и устраняет дублирование между Always-инъекцией и on-demand активацией. См. ADR docs/adr/2026-06-17-skill-activation-tracking.md (раздел «Discovery: упрощение buildSkillsBlock»).

  • Backend (Go): новый тип SkillsConfig + LoadSkillsConfig(s, agentID) в internal/agent/skill_config.go (по образцу SubagentsConfig); fail-safe ко всем путям отказа (nil-store, нет агента, пустой/мalformed Config — всегда возвращается дефолт true, ничто не ломается у existing-агентов). buildSkillsBlock (internal/agent/context.go) переписан с gated-веткой на LegacyInjection; defensive copy слайса скиллов (не алиасит store-owned backing array). Поле skills_config добавлено в UpdateAgent handler (internal/server/handler/agent.go) с merge в существующий agent.Config JSON по образцу subagents_config/strategy_config; DRY-рефакторинг условия hasSubconfigs, обработка corrupt-existing-Config через 500 вместо silent swallow.

  • Frontend (Svelte 5): тип SkillsConfig и поле skills_config в UpdateAgentParams (web/src/lib/api/types.ts); SectionCard «Настройки отображения» с toggle и спиннером на странице /agents/{id}/skills (только для админа агента), чтение из agent.config через api.agents.get, сохранение через api.agents.update с toast-нотификацией.

  • Тесты: 8 unit-тестов на LoadSkillsConfig (skill_config_test.go: nil-store, нет агента, empty/no-skills/ malformed-JSON, explicit true/false, preserves-other-sections); 4 integration-теста на buildSkillsBlock (context_skills_test.go: legacy true/false, default-is-legacy, saves-tokens); 4 handler-теста на merge skills_config (agent_config_test.go: stored, preserves-other-sections, toggle, no-inject-when-absent). Всего +16 новых тестов.

  • Проектная документация: отслеживание выполнения скиллов — ADR docs/adr/2026-06-17-skill-activation-tracking.md + план реализации docs/plan-skill-activation-tracking.md. Предлагает явную фиксацию старта/остановки работы над скиллом через новый tool skill с actions begin/end/activate, ride-along на существующий tool-call pipeline (AgentHook, WS tool_start/tool_end, ToolAuditHook). Поверх — доменный слой из двух таблиц (skill_runs + skill_run_steps) и SkillRunStepsHook со стеком активных runs для nested. Сравнение с референсами (opencode, openclaw), forward-compat поля под три будущих use-case’а (счётчики запусков, flow-просмотр tool-call’ов, саморефлексия и самосовершенствование скиллов). План разбит на 5 вертикальных итераций (backend+frontend вместе, каждая независимо деплоится), с код-скелетами, SQL-миграциями (086/087/088), API-контрактами и чек-листами DoD.

Fixed (audit 2026-06-18, Этап 0 — блокеры)

  • Пустой ToolRegistry в ContextBuilder — весь блок tools/skill-tracking не рендерился в проде (Этап 0.2 аудита). В actor.go (SessionActor.run) registerToolsForAgent вызывался ПОСЛЕ ctxBuilder.BuildMessages, и в ContextBuilder передавался пустой глобальный l.registry (создаётся в NewAgentLoop и нигде не наполняется). Из-за этого buildToolsBlock(), buildToolHintsBlock(), buildScratchpadGuidance(), buildTodoGuidance() и buildSkillsBlock() → секция ## Tracking (инструкция про skill tool с action="begin"/"end") всегда были пустыми — LLM не видел ни списка доступных тулов, ни инструкции по отслеживанию скиллов. Симметричный баг в spawn_tool.go/subagent.go: там порядок вызовов корректный, но передавался тот же пустой глобальный registry вместо локального наполненного.
  • Фикс: в actor.go registerToolsForAgent поднят выше — перед созданием ContextBuilder; SetToolRegistry получает наполненный per-agent agentRegistry. В spawn_tool.go/subagent.go SetToolRegistry теперь принимает локальный registry. Дополнительно интерфейс ToolSummaryProvider расширен методом Has(name string) bool (уже был у *ToolRegistry), а ContextBuilder.hasTool/isSkillToolRegistered переписаны через Has — это заодно убирает аллокацию map в Summaries() на каждом вызове (Этап 4.5).
  • Тесты (internal/agent/context_test.go, +3 кейса): TestBuildSystemPrompt_WithSkillToolInRegistry_HasTrackingBlock (registry с skill → блок ## Tracking присутствует), TestBuildSystemPrompt_NoSkillToolInRegistry_NoTrackingBlock (registry без skill → блока нет), TestBuildSystemPrompt_EmptyRegistry_NoTrackingBlock (nil registry → блока нет, регрессионный guard против исх. бага). mockToolSummaryProvider дополнен методом Has.
  • Lifecycle-гонка в SkillReflectionService.Start (Этап 0.3 аудита). Start всегда пересоздавал stopCh, а wg.Add(1) выполнялся вне блокировки → три race-сценария: (1) повторный Start без Stop → утечка горутины (старая ждёт на потерянном канале) + рассинхрон wg; (2) Stop→Start → двойное пересоздание канала; (3) Stop во время Start между Unlock() и wg.Add(1)wg.Wait() возвращается до Add, горутина уже запущена → следующий Wait зависнет навсегда. go test -race ловит.
  • Фикс: переход с stopCh chan struct{} на context.WithCancel (как рекомендовал вердикт аудита). cancelFn context.CancelFunc хранится в структуре; Start стал idempotent (повторный Start без Stop — no-op с warn, wg.Add(1) под локом), Stop забирает cancelFn под локом и вызывает cancel() вне блокировки перед wg.Wait(). Побочное преимущество: честный restart-after-Stop (прежний код на закрытом stopCh этого не умел — новый Start после Stop молча возвращался или падал на close закрытого канала).
  • Дополнительно (по итогам ocr review): фоновый цикл отвязан от caller’s ctx (context.Background() как parent для WithCancel) — теперь lifecycle управляется только через Stop() (matches паттерн cron/heartbeat/autocompact; shutdown в gracefulShutdown явно вызывает Stop()). Это устраняет риск, что будущий caller с request-scoped ctx молча убьёт фон. Переданный ctx используется только для early-reject: если он уже отменён на момент Start, горутина не запускается (иначе runLoop вышел бы немедленно, а лог сказал бы «started», оставив сервис в рассинхрон-состоянии).
  • Тесты (internal/agent/skill_reflection_test.go, +4 кейса): TestSkillReflectionService_StartTwice_SecondIsNoOp (повторный Start = no-op + warn, второй горутины нет), TestSkillReflectionService_RestartAfterStop (Start→Stop→Start→Stop работает без зависания), TestSkillReflectionService_StopBeforeStart (Stop без Start — безопасный no-op, cancelFn=nil), TestSkillReflectionService_StartWithCancelledCtx_NoOp (Start с уже-отменённым ctx — no-op, горутина не запускается). Все тесты проходят под -race.
  • FinishSkillRun без WHERE status='running' — потеря orphan-отметки (Этап 0.4 аудита). Между вызовом SkillTool.endAction и FinishSkillRun мог сработать idle-timer и пометить run как orphaned (через MarkOrphanedSkillRuns). Тогда FinishSkillRun молча перетирал status='orphaned' на status='completed', теряя отметку orphan-страховки. Симметричный markOrphanedSkillRunsRows уже использовал WHERE status='running', а FinishSkillRun — нет.
  • Фикс: в sqlite.go/postgres.go FinishSkillRun теперь использует WHERE id=? AND status='running' и проверяет RowsAffected; при 0 rows возвращает новый sentinel store.ErrSkillRunAlreadyFinished (рядом с ErrConcurrentModification). Совместимость сохранена: skill_tool.go:endAction уже игнорирует ошибку через slog.Warn — end на уже-orphan-run только логируется и не валит turn LLM. Существующий тест TestSkillRun_Finish_NotFound (run отсутствует → nil) остаётся корректным: для not-found existing==nil → early return.
  • Тесты (internal/store/skill_runs_test.go, +2 кейса): TestFinishSkillRun_AlreadyOrphaned_ReturnsSentinelError (run → MarkOrphanedSkillRunsFinishSkillRunerrors.Is(err, ErrSkillRunAlreadyFinished), статус остался orphaned), TestFinishSkillRun_AlreadyFinished_ReturnsSentinelError (повторный FinishSkillRun на completed run → sentinel, первый completed не перетёрт).

Fixed (audit 2026-06-18, Этап 1 — безопасность)

  • IDOR в handler/skills.go — centralized ownership checks (Этап 1.1 аудита). Три handler’а (GetProposal, ApplyProposal, RejectProposal) дублировали inline-проверку prop.AgentID != agentID — хрупко, легко забыть в новом handler’е. Симметрично существующему requireSkillRunInAgent для skill_runs.
  • Фикс: новый хелпер requireSkillImprovementProposalInAgent в helpers.go (возвращает errResourceNotFound → 404 для чужих/несуществующих proposal). Inline-проверки в трёх handler’ах заменены. Договорённость об ownership-check задокументирована в комментарии к интерфейсу Store в store.go: методы GetSkillRun/ListSkillRunSteps/GetSkillImprovementProposal/ApplySkillImprovementProposal/RejectSkillImprovementProposal принимают ID без фильтрации — это ответственность handler’а.
  • Prompt-injection через proposed_content (Этап 1.2 аудита). LLM-сгенерированный контент сохранялся в БД без санитизации и — после ApplyProposal — попадал в system prompt всех будущих запусков. Если LLM (через adversarial reflection-вывод или prompt injection из логов скилла) подсунул </system> или <|im_start|>, это становилось постоянным вектором атаки.
  • Фикс: новая функция agent.ValidateProposedContent (control chars, кроме \n/\r/\t, + injection-маркеры </system>, </s>, <|im_start|>, <|endoftext|>, case-insensitive). Вызывается в reflectSkill (skip + warn — proposal не создаётся) и симметрично в handler.ApplyProposal (422 — defense-in-depth: даже если reflection-проверку обойдут, proposal создан вручную через БД, или код reflection изменят, хендлер всё равно отсечёт).
  • Тесты (internal/agent/skill_reflection_test.go, +4 кейса): TestValidateProposedContent_OK (нормальный Markdown, unicode, emoji, разрешённые whitespace), TestValidateProposedContent_RejectsControlChars (NUL/ESC/BEL/VTAB отклонены; \n/\r/\t разрешены), TestValidateProposedContent_RejectsInjectionTokens (6 вариантов маркеров, включая case-insensitive </SYSTEM>/</S>), TestSkillReflection_RejectsInjectionInProposedContent (end-to-end: reflection с </system> → proposal не создаётся). В internal/server/handler/skills_test.go (+1 кейс): TestApplyProposal_InjectionToken_Returns422 (proposal с injection, созданный напрямую через store, → 422, остаётся pending).
  • Утечка err.Error() в HTTP-ответах (Этап 1.3 аудита). 25 handler’ов в skills.go (13 мест) и agent.go (12 мест) отдавали err.Error() напрямую в 500-х ответах — это протекало детали драйвера/схемы БД ("sql: no rows", "pq: syntax error at...") клиенту. Особо отмечен agent.go:171"existing agent config is corrupt: "+err.Error().
  • Фикс: все вхождения заменены на паттерн slog.Error("<handler> store error", ctx..., "err", err) + обобщённое клиентское сообщение ("failed to list skills", "failed to update agent" и т.д.). Драйвер-специфичные детали теперь только в логах сервера, клиент получает action-ориентированное сообщение. В agent.go добавлен import log/slog и fmt (для Sprintf).
  • Clipboard copyAsJSON — утечка секретов (Этап 1.4 аудита). Кнопка «Скопировать JSON» на странице запуска скилла копировала { run, steps } без подтверждения, а в steps[].args_summary/result_summary могли быть токены, пароли, AWS-ключи (если агент читал ~/.aws/credentials или exec’нул echo $TOKEN).
  • Фикс (web/src/routes/agents/[id]/skills/runs/[runID]/+page.svelte): regex-детектор /token|api[_-]?key|secret|password|authorization|bearer|aws[_-]?(access|secret)/i склеивает все args/result; при срабатывании показывается confirm()-диалог. Дополнительно: fallback-сообщение при недоступности navigator.clipboard в не-secure context (HTTP) — теперь пользователь видит конкретную причину вместо generic «Не удалось скопировать».
  • Length caps на summary/reflection/reject_reason (Этап 1.5 аудита). LLM мог вернуть multi-MB summary/reflection (пишутся в skill_runs TEXT без лимита, дублируются в reflection-промпте, возвращаются в UI → зависания). Симметрично reject_reason — TEXT без лимита, отображается в UI без truncate.
  • Фикс: JSON Schema tool skill дополнена "maxLength": 4000 для summary и "maxLength": 8000 для reflection. В SkillTool.endAction добавлены Go-проверки len(summary) > 4096 / len(reflection) > 8192 → error (дефолт "" → "completed" оставлен — это breaking change для LLM, см. вердикт аудита). В RejectProposalhttp.MaxBytesReader(w, r.Body, 8*1024) + len(reason) > 1024 → 400.
  • Тесты (internal/agent/skill_tool_test.go, +3 кейса): TestSkillTool_EndAction_SummaryTooLong_ReturnsError (4097 байт → error), TestSkillTool_EndAction_ReflectionTooLong_ReturnsError (8193 байт → error), TestSkillTool_EndAction_SummaryAtLimit_OK (ровно 4096 — boundary-pass). В internal/server/handler/skills_test.go (+1 кейс): TestRejectProposal_TooLongReason_Returns400 (1025 байт → 400, proposal остался pending).
  • Валидация входящего SkillsConfig JSON (Этап 1.6 аудита). В agent.go:Update params.SkillsConfig (тип json.RawMessage) присваивался в raw["skills"] без проверки shape’а — клиент мог прислать "skills": "string", "skills": 42, "skills": ["array"], и всё это записывалось как легитимный config, ломая downstream JSON-парсинг в LoadSkillsConfig. Ошибка json.Marshal(raw) молча проглатывалась (updated, _ := json.Marshal(raw)).
  • Фикс (internal/server/handler/agent.go): локальный helper validateSubconfigObject проверяет, что json.RawMessage — JSON-объект (через json.Unmarshal в map[string]json.RawMessage), иначе 400. Применён симметрично к SubagentsConfig/StrategyConfig/SkillsConfig для консистентности. Ошибка json.Marshal(raw) теперь логируется через slog.Error и возвращает 500 (практически невозможно, но проглатывать нельзя — иначе configStr станет пустым и затрёт существующий config).
  • Тесты (internal/server/handler/agent_config_test.go, +3 кейса): TestUpdateAgent_SkillsConfigNotObject_Returns400 (string → 400), TestUpdateAgent_SkillsConfigArray_Returns400 (array → 400), TestUpdateAgent_SkillsConfigNumber_Returns400 (number → 400). Существующие тесты на валидный object-config остаются зелёными.
  • Дополнения по итогам ocr review (non-low замечания, не из fix-audit-problem.md):
  • CRITICAL: null проходит validateSubconfigObjectjson.Unmarshal("null", &map) не возвращает ошибку (получается nil-map), поэтому без явной проверки null прошёл бы валидацию и затёр существующий config пустым значением. Фикс: явная проверка bytes.TrimSpace(raw) не равно "null"/пусто перед unmarshal.
  • Legacy poisoned rows strip — существующий agent.Config с не-object subkey (записанный до валидации) strip-ается при чтении в Update, чтобы merge не ре-поизонил хранимый config. Логируется через slog.Warn для observability.
  • CompactionConfig валидация — симметрично subagents/strategy/skills, теперь проверяется как JSON-объект (раньше записывался любой JSON).
  • slog.Warn на validation rejection — раньше rejection (400) не оставлял server-side trace; теперь логируется agent_id/field/payload_len (без raw payload — не плодим log-injection surface).
  • slog.Warn на swallowed IDOR store error — в requireSkillImprovementProposalInAgent transient DB-ошибка (отличная от sql.ErrNoRows) логируется, чтобы не путать с легитимным “not found” в метриках.
  • Length cap 4000/8000 согласован с JSON Schema — Go-константы приведены к maxLength из JSON Schema (раньше schema обещала 4000, а Go тихо принимал до 4096 — LLM получал противоречивый сигнал).
  • Clipboard regex tighten — вместо broad /token|secret|.../i, который матчит “tokenization”/“авторизация” и вызывает security fatigue, теперь word boundaries + конкретные high-signal паттерны (sk-..., AKIA..., ghp_...). confirm() заменён на toast.warning (consistency с остальным Svelte UI, не blocking-native-dialog). Clipboard error differentiation: NotAllowedError → конкретное сообщение про permission, не-secure context → про HTTPS.
  • 413 branch для MaxBytesReader — oversized body в RejectProposal теперь возвращает 413 (не 400), по образцу provider.go, чтобы клиент мог отличить “слишком большое” от “malformed JSON”.
  • Тесты (+4 кейса): TestUpdateAgent_SkillsConfigNull_Returns400, TestUpdateAgent_CompactionConfigNotObject_Returns400, TestUpdateAgent_StripsPoisonedLegacySubkey, TestRejectProposal_OversizedBody_Returns413. Boundary-тест length cap обновлён до 4000.

Security

  • Обновление npm-зависимостей в web/. npm audit fix + npm update закрыли накопившиеся уязвимости и подтянули патч/минорные версии в рамках существующих диапазонов ^. Единственное изменение web/package.json — добавлена секция overrides (см. workaround).
  • Закрыто 3 high + 3 moderate уязвимостей (остались 4 low-severity transitive через cookie в @sveltejs/kit — ждём upstream-фикса; npm audit fix --force поставил бы @sveltejs/kit@0.0.30 и сломал проект). Излеченные: vite 8.0.10→8.0.16 (утечка NTLMv2-хэша на Windows через launch-editor, обход server.fs.deny), undici (обход TLS через SOCKS5, инъекция Set-Cookie, DoS WebSocket, отравление keep-alive), devalue (DoS через sparse array), svelte 5.55.5→5.56.3 (SSR XSS через Promise serialization, DOM clobbering, ReDoS в <svelte:element>), dompurify 3.4.2→3.4.11 (9 XSS-байпасов IN_PLACE/hooks/templates), @sveltejs/kit 2.59.0→2.67.0 (query.batch cross-talk).
  • Changed (патч/минор в рамках ^): @sveltejs/vite-plugin-svelte 7.0.0→7.1.2, tailwindcss/@tailwindcss/vite 4.2.4→4.3.1, svelte-check 4.4.7→4.6.0, eslint 10.4.1→10.5.0, @typescript-eslint/* 8.61.0→8.62.0, cytoscape 3.33.3→3.34.0, marked 18.0.3→18.0.5, prettier 3.8.3→3.8.4, prettier-plugin-svelte 4.1.0→4.1.1, @testing-library/svelte 5.3.1→5.4.2, @vitest/ui/vitest 4.1.8→4.1.9, @types/node 25.9.2→25.9.4 (остался на ^25, мажор 26 не тронут).
  • Workaround — overrides.esrap = "2.2.6" в web/package.json. esrap ≥2.2.7 (transitive через svelte) регрессионно ломает типо-стриппинг <script lang="ts">: опциональные параметры (function safeLang(lang?: string)) после удаления типов превращались в lang? — синтаксическая ошибка для rolldown SSR-transform в vitest, падал MarkdownRenderer.test.ts (410→401 тестов, 1 suite failed). Бисект от исходного package-lock.json изолировал виновника до esrap 2.2.6→2.2.12. Пин к 2.2.6 восстанавливает корректный codegen. Override убирается, когда svelte/esrap выпустят фикс.
  • Проверки: npm run check — 0 ошибок (315 warnings — существующие a11y), npm test — 410 тестов, make build-webui + make build — ОК, go test ./... — зелёный. Lint-замечания (6 errors, 90 warnings) — существующие, подтверждены baseline-сравнением (git stash + npm ci), не вызваны обновлением. ocr review — 0 замечаний.
Архивная секция Unreleased (дубликат из исходного файла)

Changed

  • W-015 (завершение, Группа 4): routes/chat/+page.svelte (536 → 250 строк, -53%) — 7 callbacks (getAgentDisplayName/getAgentId/setupResizeObserver/scrollToBottom/getStickToBottom/setStickToBottom/resyncMessages) удалены из ChatStore. Store теперь сам владеет agentId/agentName (через новый метод setAgent), а resyncMessages стал приватным методом store. Scroll/DOM-логика вынесена в новые $state-классы:

  • lib/features/chat/useChatScroll.svelte.ts (85 строк) — ChatScroll инкапсулирует container/stickToBottom/hasMoreMessages/loadingMore/loadingHistory, ResizeObserver, scroll-to-bottom с rAF-throttle, onScroll-load-more, withLoadingHistory() обёртку.

  • lib/features/chat/useChatCopy.svelte.ts (23 строки) — ChatCopy с copiedMsgId + cleanup таймера.

  • lib/features/chat/useChatSwitch.svelte.ts (188 строк) — ChatSwitch инкапсулирует init()/switchTo()/loadMore()/handleModelSwitch() с AbortController-логикой, race condition защитой, error-handling, deps (getAgents/getCurrentAgent/chatStoreRef/scroll/drafts).

  • lib/features/chat/ChatMessages.svelte (80 строк) — большой div с bind:this messagesContainer + MessageList/ChatThinking/ChatToolPanel/StreamingMessage/ChatAskUser инкапсулирован в отдельный компонент.

  • lib/features/chat/EmptyState.svelte (5 строк) — удалён как мёртвый код (создан в Группе 3, но нигде не импортировался).

  • W-015-UX: фикс мерцания empty-state при загрузке/переключенииMessageList.svelte обновлён: добавлен prop loadingHistory. В блоке {#if messages.length === 0}:

  • если loadingHistory === true → спиннер с текстом “Загрузка истории…”

  • иначе (история действительно пуста) → empty-state “Начните диалог с агентом”

loadMessages() обёрнут в scroll.withLoadingHistory() — флаг loadingHistory = true ставится ДО messages = [], сбрасывается в finally после загрузки. Результат: при переключении агентов больше не видно мелькания “Начните диалог с агентом” — UI показывает спиннер до загрузки новой истории.

ChatStore без callbacks: ChatStoreCallbacks interface удалён, private callbacks поле удалено, все this.callbacks.getAgentId()this.agentId, this.callbacks.getAgentDisplayName()this.agentName. Constructor теперь без параметров.

Результат: page ровно 250 строк (целевой <250 достигнут), npm run check: 0 errors, npm run build успешен (2.16s). Файлы — lib/stores/chat.svelte.ts (669 → 678 строк, +9 — добавлены setAgent/agentId/agentName/private resyncMessages).

  • W-019 (завершение, Группа 4): routes/agents/[id]/memory/+page.svelte (529 → 115 строк, -78%) — 22 callback-функции между page и табами вынесены в MemoryData class (lib/features/memory/useMemoryData.svelte.ts, 600 строк): loadFacts/addFact/deleteFact/deactivateFact/reprocessFactKG/toggleHistory/exportMemory/importMemory/doSearch/uploadDocFile/fetchDocURL/indexDocument/reindexSource/deleteChunks/runConsolidation/runDream/loadDreamRuns/loadScratchpad/clearScratchpad/toggleRunActions/toggleSetting/saveDreamSetting/saveCompactConfig/onCompactPresetChange. Также перенесён $state для 3 модалок (AddFact/ImportFacts/AddDocument), lazy-load state (dreamRuns, scratchpadData, openHistory, expandedRun), ConfirmState с методами runConfirm/cancelConfirm. Bindings 6 табов изолированы в новый MemoryTabContent.svelte (99 строк) — page остаётся только с layout (MemoryOverview/Metrics/Tabs) + 3 модалки + ConfirmModal. Целевой <150 строк достигнут (фактически 115). npm run check: 0 errors.

Реализованы 12 P2-задач из аудита docs/audit/web-2026-06-06.md (UX/perf/cleanup/build). Из 14 P2-задач исключены W-038/W-039 (ложные баги), W-041/W-042 уже выполнены попутно в Группах 2/3. Подробности реализации — в описаниях задач в docs/audit/web-2026-06-06.md.

  • W-032: autoResize в ChatComposer обёрнут в requestAnimationFrame — добавлен флаг resizeScheduled, высота textarea пересчитывается не чаще 1 раза за кадр. Устраняет layout thrash при paste / быстром вводе.
  • W-033: resendToComposer показывает toast.info — при переносе сообщения в поле ввода всплывает тост «Сообщение перенесено в поле ввода», иначе на больших экранах теряется из вида.
  • W-034: Avatar с loading="lazy" — добавлен prop lazy: boolean = true (по умолчанию 'lazy', для header’ов можно передать false'eager'). Также decoding="async" и fetchpriority (auto для lazy, high для eager). Существующие вызовы автоматически получают lazy-loading, т.к. дефолт true.
  • W-035: MarkdownRenderer throttled 100мс$derived рендерит не чаще 1 раза в 100мс, при пропущенных тиках возвращает закешированный HTML. При стриме 50 токенов/сек — marked.parse() + DOMPurify.sanitize() вызываются ≤10 раз/сек вместо 50.
  • W-036: download-ссылки через post-process в MarkdownRendererextractDownloadLinks и блок рендеринга удалены из ChatMessage.svelte. В MarkdownRenderer postProcessDownloadLinks() заменяет <a href=".../files/download..."> на стилизованный .download-link блок (иконка + label + «скачать»). Устраняет двойной рендеринг ссылок и жадный regex.
  • W-037: empty-state CTA в FavoritesPanel — добавлена кнопка «Перейти к чату» (акцентный primary action, onclick={closePanel}).
  • W-040: empty-state в ChatSidebar — для пустого списка агентов: админу — CTA-ссылка на /settings/agents, не-админу — текст «обратитесь к администратору». Импортирован isAdmin из $lib/stores/auth.svelte.
  • W-043: copyTimer cleanup в chat/+page.svelte — добавлен let copyTimer рядом с saveTimer, clearTimeout при повторных кликах и в onMount cleanup. Устраняет утечку таймеров и phantom-writes в state при unmount.
  • W-044: @types/node установлен — устраняет warning Cannot find type definition file for 'node' в svelte-check. Зависимость добавлена в web/package.json devDependencies.
  • W-045: manualChunks для cytoscape в vite.config.ts — реализован через функцию (Vite 8 не принимает object-form manualChunks). cytoscape + cytoscape-cose-bilkent попадают в отдельный чанк vXo04wG0.js (511 КБ), который грузится только при открытии вкладки «Граф» в /agents/[id]/kg.
  • W-046: ADR «inline-script темы» — оставить как есть — создан docs/adr/2026-06-06-theme-inline-script.md. Все альтернативы (type="module" defer, @media (prefers-color-scheme: dark), ThemeProvider.svelte) приводят к гарантированному FOUC или ломают явный выбор темы. Inline-скрипт 2-3 мс — приемлемо для desktop-only приложения.
  • W-047: window.location.*goto() в 4 файлах+layout.svelte (3 замены), login/+page.svelte (2), onboarding/+page.svelte (1), ChatHeader.svelte (logout, 1). Logout через api.logout в lib/api/client.ts оставлен с window.location.href (нужна полная перезагрузка для сброса state).

Bundle

✓ built in 2.23s

После введения manualChunks для cytoscape, страницы /agents/[id]/* (кроме /kg) не тянут cytoscape. Проверено: только build/_app/immutable/chunks/vXo04wG0.js содержит подстроку cytoscape.