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раньше только писалась, но не читалась в модельAgent—runOnceпередавал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.go→bgWG-горутина →MaybeCompactAsync), а не по таймеру. Решение:historyTokens ≥ budget.History × softThreshold%(по умолч. 70%, token-trigger) ИЛИpairsCount ≥ keepPairs + 4(pair-trigger, hard-cap). Token-trigger использует per-agentContextMaxTokensчерезComputeBudget. Pair-trigger защищает от множества коротких пар. Per-agentTryLock(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-agentcompaction_configJSON) реально доходят до AutoCompact черезresolveAgentConfig→agent.ResolveCompactConfig(). РаньшеSetKeepLast/SetResolveConfigбыли определены, но не вызывались — AutoCompact работал на жёстких константах. - Удалено:
AutoCompactService(service+test),MaybeCompact(TTL-логика),GetAgentsCompactedAt(batch, purpose-built под сервис), settingautocompact_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.5→w-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: 0→ChatMessage.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-agentqueue_*,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(switchrole="switch", дизайн-токены вместоtext-green-600) иParameterRow.svelte(единый виджетAdvancedParam: type-based контролbool→Toggle/int/float→numberINPUT_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-алиас); paddingmdвозвращён к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 byentry.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-agentqueue_pending/queue_running/queue_failed/memory.agents[*].access_zero/unused_30d/by_subject/updated_at); селект агента →INPUT_CLASS;min/maxlatency-спарклайна вынесены из.map(O(n²)→O(n));strokeчерезtoneVar.amberвместо хардкодаvar(--c-amber);role="alert"на баннере./settings/system+ 6 дочерних компонентов полностью рефакторинг:+page.svelte→SettingsPageHeader+Button size="sm";SystemInfoCard→Cardс dl-сеткой +details(tray_pid,has_tray,supervised,managed_externally,uptime_human);UpdatesCard→Card+Button/details(supervised,auto_update_supported,platform), сырая confirm-модалка →ui/Modal+disabledна install-кнопке;SystemActions→Card+Buttonprimary/danger с иконками +ui/Modal(сырые 2 модалки →ui/Modal),managed_externally→Badgewarning;SecurityCard→Card+INPUT_BASE-моно для textarea (вместо bespoke-класса) +Badgeinvalid +detailsдля сырогоwhitelist,text-whiteзаменён наtext-accent-fg;DiagnosticsCard→Card+Button;RollbackBanner→ warning-баннер сBadge+Buttonghost, показаныat(formatDateTime) иrolled_back./settings/users: bespoke<table>→ списокResourceCard(icon=инициал/аватар, badges=рольBadgepurple=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;ReindexSection→Card+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); при открытом breakerRerankвозвращал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; allowlistkindдля провайдеров убрано значение"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 vsResourceCard), контролы то кнопки то текстовые ссылки то иконки-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-контент вынесен в локальный snippetheaderContentдля бездублирующего рендера в обеих ветках; корневой 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(бейджиbuiltinpurple /alwayssuccess, meta — описание + приватный агент, в «Детали» —contentпревьюline-clamp-6+ «полностью в редакторе»,metadatafont-mono, даты), футер actions сButton«Изменить» (goto) + «Удалить» (скрыт дляis_builtin).EmptyStateс actions. Создание —Button+goto('/settings/skills/new')./settings/mcp: ручной<div>→ResourceCard(icon-квадрат с SVG по дизайн-токенам, бейджиtypeinfo + OAuth-статусsuccess/warning/dangerчерезoauthBadge()— заменил сырой tailwindtext-yellow-600/text-green-600, meta —command/argsилиurlfont-mono + таймаут, в «Детали» —env/headersfont-mono break-all как в providersextra_*,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 — slugname, бейджиis_builtinpurple /!enableddanger, meta —toolsSummaryчерезparseToolsWhitelist+ «без делегирования»/«своя модель»/temp/max_iterations/timeout, в children —display_name+description, в «Детали» —system_prompt/output_contractпревьюline-clamp+ «полностью в редакторе»,mcp_whitelistfont-mono,sort_order, даты), футер actions «Изменить»/«Удалить» (per-rowdeletingIdsguard и async-onConfirmConfirmModal сохранены).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, paddingp-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(единый overlaybg-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_windowtokens) — через·-разделитель.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+ guardasModelKind(приводит строковый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
toolsparameter andtool/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_callingper-provider в БД (миграция 096, колонкаBOOLEAN NOT NULL DEFAULT 1вllm_providers, парные SQLite/PG миграции). UI-тoggle в advanced-секции формы провайдера (ProviderFormModal.svelte). Create handler defaults поkind: толькоllm→true,embedding/reranker→false; 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);tool→userс 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” → конструкторский defaulttrueсохраняется (защищает literal-construction call-сайты: тесты, diagnostics, handler). DB-backed constructors (CreateFromLLMProviderConfig/CreateFromProviderAndModel) передают явное значение из строки; legacyLoad(agent:%d:* settings) —nil.UI toggle.
ProviderFormModal.svelte: чекбокс «Поддержка tool calling» с описанием. Поле всегда отправляется (не условно) — toggle должен persistиться даже вfalse.Известное ограничение (задокументировано): live
*OpenAICompatProviderinstance не 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, darwinsh) при вызове без флагов ставят одновременно soft И hard (man bash: “If neither -H nor -S is specified, both the soft and hard limits are set”). Hard limit непривилегированный child не поднимет безCAP_SYS_RESOURCE—ulimit -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(через LLMaskMerge) вызывался для любого дубликата с sim ≥dedup_llm_check_threshold(см. ADR2026-05-22-fact-evidence-subject.md:88-96). Параметр удалён изParamRegistry, gettergetDedupCosineThresholdи константа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.SSHjump-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для тестов, fallbacknet.DefaultResolverв проде) и методResolveHostForExec(ctx, host)— резолв ОДИН раз с fail-closed проверкой приватности и short-circuit для IP-литералов (без DNS). НовыйExecGuard.SanitizeNetworkHosts(cmd)переписывает каждый hostname в команде на пиннутый IP-литерал: ssh-таргетuser@host→user@<ip>+-o HostKeyAlias=<host>(сохраняет проверку ключа),-J/-W/-o ProxyJump=→ IP в значении, scp/rsyncuser@host:path→user@<ip>:path, telnethost 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-onlyPOST /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(при установленном guardTestConnectionк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лок временно отпускался (Unlock→CheckNow→Lock), но публичныйCheckNowсам делаетTryLock→Unlockи не держит лок во время сетевого fetch’а манифеста. В окно между отпусканием и возвратом второй параллельныйPOST /api/v1/updates/installпроходилTryLockи начинал скачивание → оба доходили доapplyUpdate→ повреждённый бинарник/сломанный backup.Фикс. Из
CheckNowвынесен приватныйcheckNowLocked(ctx)(вся логика fetch/verify/state-update безinstallMu; caller обязан удерживать лок).CheckNowтеперь: dev-чек →TryLock→defer Unlock→checkNowLocked. В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продолжал ходить векторным путём, хотя вектора в БД — смесь старых/искажённых/новых с рассинхроном размерностей (на PostgreSQLALTER TABLE … TYPE vector(N)переписывает колонку целиком до пересчёта). ТеперьReindexerвыставляет in-memoryatomic.Boolфлаг (InProgress()), который читается RAG-путём через интерфейсagent.ReindexProgress(duck-typed, без cyclic import). ПриInProgress()==trueRAG деградирует до FTS-only fallback: пропускаютсяEmbed(query),SearchFacts/SearchChunks(vector) иapplyMMR; сохраняютсяSearchFactsFTS/SearchChunksFTS,RRFMerge, time-decay, importance-weight и rerank. Флаг сбрасывается вdefer(даже при panic). Дополнительно: на PGEnsureVectorDimensionsобёрнут в 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.go→collectStreamнапрямую (без двойного 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, propsrows/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 колонок), propsrows/cols/class. audit, llm-log, users, skills/runs.LineFormSkeleton.svelte— форма (label+input строки), propsfields/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 + секции), propscards/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-700→bg-success;bg-green-100 text-green-700→bg-success-bg text-success-text;bg-red-100 text-red-700→bg-danger-bg text-error-text;bg-gray-300 text-gray-700→bg-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-8→w-10 h-10(40px), внутренний SVGw-4 h-4→w-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заменяли свои 3animate-pulseспана на единый<TypingDots>. Консистентность с ChatThinking/StreamingMessage.Хардкод-цвета мимо токенов:
ChatToolPanel.svelte:175text-gray-400→text-hint;ChatAskUser.svelte:16hover:border-blue-500→hover: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-drivenICON_PREFIXEStable.- Суммарное время группы итераций в
ToolIterationCard.svelte—$derivedчерез min/maxstarted_at/completed_at, fallback на суммуtook. Плашка· {groupDuration}в шапке рядом со счётчиком(N). utils/parseDuration.ts+ 7 unit-тестов —parseGoDurationMs()парсит вывод Gotime.Duration.String()(включая multi-unit “1m30s”, “2m0.5s”, “1h0m0.5s”) в ms. Используется вgroupDurationfallback. Решает баг: исходный 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), спарклайн тренда задержки,Barscalls/errors, блок итераций с лимитом. - KG per-agent:
Barsentities с caption relations + summary-строка (экстракций/дедупликации/ср. время). - Memory per-agent:
Barsactive_facts +Donutby_category с селектором агента (effectiveCategoryAgentIdfallback через$derived). - Mobile-first:
metrics-chart-gridадаптив (donut centered + легенда ниже на <768px),grid-cols-1 sm:grid-cols-2 lg:grid-cols-*. - Рефакторинг charts-кита: выделен
charts/tone.tsс единым типомTone+toneVarlookup-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,parseIntguard, magic numberPOLL_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 (3animate-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— backdroppx-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:385—grid-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.png1x →android-chrome-192x192.png2x →android-chrome-512x512.png3x) вместо грузящего 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). Изображение одинаково в обеих темах. Убраны propsmonoи случайный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(ADR2026-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в форму (устраняет 12state_referenced_locallywarnings 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вActiveSubagentchat-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 backendisReasoningModel), константы лимитов (mirror backendvalidateProfileFields) с TODO вынести на сервер для устранения drift.Per-profile
can_spawn(Forward-compat пункт 4):Миграция
094_profile_can_spawn(SQLite + PG, defaulttrue/TRUE— backward-compatible). Колонка задаёт, имеет ли профиль право порождать дочерних подагентов.Post-filter
excludeSpawnForNoSpawnProfile(internal/agent/subagent_profile.go): при!profile.CanSpawnspawn-tool убирается из registry child’а (после depth-guard и intersection).spawn_cancelостаётся — агент на любой глубине должен управлять in-flight детьми. Defaulttrueсохраняет прежнее поведение;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-заявки ранее не закрыты):truncateSummarybyte→rune (internal/agent/subagent.go),globalConcurrentLimitsettings-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-fixSetSubagentProfiles(упразднить ссылку на несуществующий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.Temperature→spec.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-nilspec.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.MaxChildrenPerAgentenforcement: счётчик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.Enabledenforcement: spawn-tool не регистрируется приsubagents.enabled=false. Раньше поле вводило в заблуждение.Snapshot semantics + graceful cleanup:
SubagentTask.Profilesnapshot;cleanupSubagentsждёт детей (WaitForChildren, 3с) и помечает зависшие runs как orphaned (MarkOrphanedSubagentRuns).PromptMode гейт fix:
resolvePromptModeгейтb.Mode != 0→b.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_builtinprotection: 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
ErrBuiltinProfileProtectedcoverage.Проверки:
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/htmlURL уже вырезаны — 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.tsload-функции, выбрасывающие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 AgentTool→interface PermanentMemoryItem(поля без изменений). Группа методовapi.agentTools→api.permanentMemory(сигнатурыlist/create/update/deleteбез изменений).audit.agentTools(метод аудита tool-calls LLM) иauditTab === 'tools'(вкладка аудита) — НЕ затронуты, другой доменный смысл.lib/components/ToolEditor.svelte→PermanentMemoryEditor.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/toolId→item/items/itemIdв новых файлах. - Сопутствующий фикс: баг русской плюрализации в счётчике записей (
n === 1 ? 'запись' : n < 5 ? 'записи' : 'записей'неправильно обрабатывал 11–14 и 21–24). Добавлен helperpluralize(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 indexmemory_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 utilityformatDateTime(консистентность с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/improvements—loadingникогда не сбрасывался из-за 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дропался (earlyreturn), аif (!req.isStale(myToken)) loading = falseвfinallyне выполнялся —loadingнавсегда оставалсяtrue. Автор #181 знал об этом классе race и защитилloadAgentSkillsотдельнымcancelled-guard’ом (комментарий в коде: “иначе в Promise.all монотонный req.next сразу сделает этот токен stale”), ноloadAnalyzeStatusподключили к тому жеreq, повторно введя баг. Race недетерминирован по таймингу сети: при тестировании #181analyzeStatusresolving’ся медленнееlistProposals, баг не всплывал; с другим таймингом (тёплый кэш GET /analyze, быстрый ответ)analyzeStatusопережал и ломалloadProposals.Фикс: отдельный cancellable-request instance
statusReqдляloadAnalyzeStatus(web/src/routes/agents/[id]/skills/improvements/+page.svelte). Изоляция token-space устраняет cross-invalidation:loadProposalskeepsreq,loadAnalyzeStatusиспользуетstatusReq.visibilitychangeпо-прежнему инвалидизирует только свои устаревшие status-запросы (race-protection при множественных возвратах фокуса сохранён).Поведение после фикса:
loadProposalsresolves →req.isStale(1)=false→ данные присваиваются,loading=false;loadAnalyzeStatusresolves →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 на отдельной строке). HelperanalysisRawToStringобрабатывает 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вызывал barej.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= settingskill_reflection_enabled=="true"(fail-closed при ошибке GetSetting,slog.Debugдля диагностики «кнопка disabled у всех»). Handler не валидирует agentID — статус глобальный per-instance, parent middleware проверяет membership. РоутGET /analyzeзарегистрирован ДО/{proposalID}(статический сегмент не должен поглощаться параметром). На фронте: кнопкаdisabledкогда анализ выключен/недоступен/идёт ИЛИ пользователь не agent admin (canEditAgent— non-adminagent_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, cleanupAnalyzeStatus(убран неиспользуемыйagentID-парсинг,writeJSONhelper дляX-API-Version,slog.Debugпри ошибке GetSetting),SetLoggerrace-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, defaultfalse. Тип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-список.isBoolOnhelper + нормализация bool приloadAdvanced/resetAdvancedParam—advancedEditValuesвсегда содержит 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-dropdown→user-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-пакете + setterSetReflector(по образцуChatHandler.SetConsolidator, т.к.reflJobсоздаётся позже handler’а в main.go). МетодAnalyzeNow(internal/server/handler/skills.go): nil-reflector → 503 (провайдер не сконфигурирован); feature-флагskill_reflection_enabled != "true"→ 403;TryStartReflectfalse → 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-leaksetTimeout(capture handle,clearTimeoutвonDestroy), panic skipsFinishReflectв cron-пути (вынесено вreflectAgentGuardedсrecover()), логированиеGetSettingerror, DRY-таймаут (agent.SkillReflectionTimeout), английские API-сообщения. Второй — исправлены:FinishReflectпослеrecover()(разделены на два defer, чтобы panic вFinishReflectтоже ловился), stale-timer при повторном клике (clearTimeoutперед новымsetTimeout), визуальный индикатор выполнения, тест на panic-recovery. Low/nit (renamereflectMu, 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оставляла полу-руны перед «…»). Переписана: параметр переименованmaxLen→maxRunes, единый контракт через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 fallbackEvalSymlinks(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 зона без минут. Layout2006-01-02 15:04:05Z07:00требует+03:00, поэтомуParseTimeвозвращал ошибку → вFinishSkillRunиpgMarkOrphanedSkillRunsRowsdurationMsоставался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 regexppgShortTZReи 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). HandlerListProposalsпарсит?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) + извлечение composablecreateCancellableRequest(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 | string→SkillRunStepSource,SkillRunStep.status: string→SkillRunStepStatus. Вendpoints.ts:767listRunsпараметрstatus?: string→status?: SkillRunStatus. СозданSkillImprovementAnalysisinterface ({analysis?, runs_used?, generated_at?}— согласован с реальным outputskill_reflection.go:242-246) и подключён в consumer (parseAnalysisв improvements вместо inline-типа). Устаревший комментарий в Gostore.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). Метод не меняет поведение для типовых контентов (сотни строк).createCancellableRequestcomposable (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-visiblering (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-onlysummary (раньше динамический индикатор активности был невидим скринридеру).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-4→grid-cols-2 sm:grid-cols-4; avg/medianjustify-between→flex-wrap. Шапка skills с ссылками «Улучшения→»/«История→» получилаflex-wrap(раньше на узких налезали). Кап отступа nested-timeline (depthIndent) на4rem— глубокая вложенность больше не вызывает горизонтальный скролл.Тесты (7.5) —
formatSkillRunDuration(0)теперь'0мс'(было'—'); добавлены edge-casesNaN/Infinity→'—'.computeLineDifflarge-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возвращается существующий sentinelstore.ErrConcurrentModification. HandlerApplyProposalмапит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_usertool-call; без него CHECK сломал бы запись шага) +source IN ('live','tool_audit','message');skill_improvement_proposals—chk_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-layermaxProposedContentBytes). В 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 (PGSQLSTATE 23505через*pgconn.PgError+ SQLiteSQLITE_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(канон проекта, как в skillsruns/[runID]). Новая утилитаweb/src/lib/utils/parseRouteId.ts— regex/^\d+$/+Number.isInteger+>0(NaN для malformed'123abc'/'1.5'/'-1', раньшеparseIntмолча обрезал).Unauthorized→goto('/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заменён на логирование; последовательныйfor→Promise.all(N+1 fix).Этап 4 — Редакторы: ограничения ввода и a11y (v0.142.14):
maxlength+ счётчик на textarea (name 200, message 10000 cron, title 200, description 4000 heartbeat — мегабайтный paste больше не уходит целиком).model_idselect → 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-lint0 issues,npm run check0 errors,npm test410 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 accessorStore.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 обновлены. В godocContextBuilderдобавлено предупреждение о небезопасности для 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дней, cappedskillRunStatsMedianCap = 1000значений на скилл (in-placesort.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, JOINskills ⨝ agent_skillsсWHERE agent_id=? AND name=? AND enabled) заменяет прежние два round-trip’а (GetSkillByName+ListEnabledSkillsForAgent+ client-side linear scan) вSkillTool.lookupEnabled. Добавлен sentinelstore.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) — renamelookupEnabled→lookupEnabledForAgent(симметричноListEnabledSkillsForAgent/GetEnabledSkillForAgent). User input (имя скилла) больше не попадает в сообщение об ошибке — sentinelstore.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.1→1.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 blockreturn {}). Заменён наjson.Decoder.Decode— стандартная библиотека корректно пропускает строковые литералы. Новый тестTestParseReflectionResponse_JsonWithBraceInString.skillMetadrift guard (6.3) — compile-time assertion (type conversionskillMeta↔skills.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.go—ModelIDOverride()(8 случаев: nil/missing/int64/int/float64/zero/negative/wrong-type);internal/agent/loop_model_override_test.go—ResolveProviderForModel(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: раньше при переключенииat→every/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()). Чтение переведено на новый accessorgetAgentID().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 через settingskill_reflection_enabled) для каждого скилла агента с ≥10 завершёнными runs за последние 30 дней собирает(skill_runs + skill_run_steps + reflection), отправляет LLM промпт-анализ (паттерны дубликатов, откаты, ошибки, длинные chains, driftskill_content_hash) и сохраняет улучшенный контент какpendingproposal. Пользователь видит страницу/agents/{id}/skills/improvementsсо списком proposals, построчным LCS-diff (current vs proposed), ссылками на related runs, и кнопками Apply / Reject (с textarea для reason). Закрывает use-case 3 ADR поверх готового доменного слоя из итерации 3. См. ADRdocs/adr/2026-06-17-skill-activation-tracking.md(раздел «Forward-compatibility» → «3. Саморефлексия»).Backend (Go): миграция
088_skill_improvement_proposals(4 файла SQLite+PG, сchk_skill_proposals_statusenum-constraint,reject_reasonполем). ТипSkillImprovementProposal+ 6 методов интерфейсаStore(CreateSkillImprovementProposal,GetSkillImprovementProposal,ListSkillImprovementProposals,HasPendingSkillImprovementProposal,ApplySkillImprovementProposal(atomic tx: UpdateSkill.Content + инкрементSkillMetadata.Versionчерез локальныйskill_metadata.gohelperincrementSkillVersion+ mark proposal applied),RejectSkillImprovementProposal(с reason, guardstatus='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через settingskill_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-правок. См. ADRdocs/adr/2026-06-17-skill-activation-tracking.md(раздел «Forward-compatibility» → «2. Flow-просмотр запуска»).Backend (Go): два handler-метода в
SkillsHandler(internal/server/handler/skills.go) —GetRun(для шапки страницы) иListRunSteps(timeline). Оба используют зарезервированный в итерации 3requireSkillRunInAgentдля 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(отдельный enumrunning|completed|error, fallback«неизвестно: X»/bg-dimmed text-labelдля будущих статусов). Строки таблицы вruns/+page.svelteстали кликабельными с keyboard-поддержкой (role="link"/tabindex="0"/onkeydownEnter/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}) с общим fixtureseedRunWithSteps; 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-правок. См. ADRdocs/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-медианы). Toolskill(skill_tool.go) расширен записью lifecycle:beginAction→CreateSkillRun(возвращает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-timerSessionActor.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_injection—buildSkillsBlockтеперь разделяет поведение по флагуskills.legacy_injectionвagent.Config. Приtrue(дефолт) сохраняется прежнее поведение: скиллы сAlways=trueи совпавшие по триггерам целиком инжектируются в системный промпт. Приfalseвсе скиллы показываются LLM только каталогом (name + description) в секции «Available Skills» — полный контент LLM подтягивает сама через toolskill(action=activate). Экономит токены (kilobytes контента на каждый скилл), даёт явный сигнал телеметрии «LLM решила загрузить этот скилл» и устраняет дублирование между Always-инъекцией и on-demand активацией. См. ADRdocs/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добавлено вUpdateAgenthandler (internal/server/handler/agent.go) с merge в существующийagent.ConfigJSON по образцу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-теста на mergeskills_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. Предлагает явную фиксацию старта/остановки работы над скиллом через новый toolskillс actionsbegin/end/activate, ride-along на существующий tool-call pipeline (AgentHook, WStool_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(инструкция проskilltool сaction="begin"/"end") всегда были пустыми — LLM не видел ни списка доступных тулов, ни инструкции по отслеживанию скиллов. Симметричный баг вspawn_tool.go/subagent.go: там порядок вызовов корректный, но передавался тот же пустой глобальный registry вместо локального наполненного. - Фикс: в
actor.goregisterToolsForAgentподнят выше — перед созданиемContextBuilder;SetToolRegistryполучает наполненный per-agentagentRegistry. Вspawn_tool.go/subagent.goSetToolRegistryтеперь принимает локальный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.goFinishSkillRunтеперь используетWHERE id=? AND status='running'и проверяетRowsAffected; при 0 rows возвращает новый sentinelstore.ErrSkillRunAlreadyFinished(рядом сErrConcurrentModification). Совместимость сохранена:skill_tool.go:endActionуже игнорирует ошибку черезslog.Warn— end на уже-orphan-run только логируется и не валит turn LLM. Существующий тестTestSkillRun_Finish_NotFound(run отсутствует → nil) остаётся корректным: для not-foundexisting==nil→ early return. - Тесты (
internal/store/skill_runs_test.go, +2 кейса):TestFinishSkillRun_AlreadyOrphaned_ReturnsSentinelError(run →MarkOrphanedSkillRuns→FinishSkillRun→errors.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— попадал вsystemprompt всех будущих запусков. Если 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добавлен importlog/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-MBsummary/reflection(пишутся вskill_runsTEXT без лимита, дублируются в 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, см. вердикт аудита). ВRejectProposal—http.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). - Валидация входящего
SkillsConfigJSON (Этап 1.6 аудита). Вagent.go:Updateparams.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): локальный helpervalidateSubconfigObjectпроверяет, что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проходитvalidateSubconfigObject—json.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 — вrequireSkillImprovementProposalInAgenttransient 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и сломал проект). Излеченные:vite8.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),svelte5.55.5→5.56.3 (SSR XSS через Promise serialization, DOM clobbering, ReDoS в<svelte:element>),dompurify3.4.2→3.4.11 (9 XSS-байпасов IN_PLACE/hooks/templates),@sveltejs/kit2.59.0→2.67.0 (query.batchcross-talk). - Changed (патч/минор в рамках
^):@sveltejs/vite-plugin-svelte7.0.0→7.1.2,tailwindcss/@tailwindcss/vite4.2.4→4.3.1,svelte-check4.4.7→4.6.0,eslint10.4.1→10.5.0,@typescript-eslint/*8.61.0→8.62.0,cytoscape3.33.3→3.34.0,marked18.0.3→18.0.5,prettier3.8.3→3.8.4,prettier-plugin-svelte4.1.0→4.1.1,@testing-library/svelte5.3.1→5.4.2,@vitest/ui/vitest4.1.8→4.1.9,@types/node25.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изолировал виновника доesrap2.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обновлён: добавлен proploadingHistory. В блоке{#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 и табами вынесены вMemoryDataclass (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"— добавлен proplazy: boolean = true(по умолчанию'lazy', для header’ов можно передатьfalse→'eager'). Такжеdecoding="async"иfetchpriority(auto для lazy, high для eager). Существующие вызовы автоматически получают lazy-loading, т.к. дефолтtrue. - W-035:
MarkdownRendererthrottled 100мс —$derivedрендерит не чаще 1 раза в 100мс, при пропущенных тиках возвращает закешированный HTML. При стриме 50 токенов/сек —marked.parse()+DOMPurify.sanitize()вызываются ≤10 раз/сек вместо 50. - W-036: download-ссылки через post-process в
MarkdownRenderer—extractDownloadLinksи блок рендеринга удалены изChatMessage.svelte. ВMarkdownRendererpostProcessDownloadLinks()заменяет<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:
copyTimercleanup вchat/+page.svelte— добавленlet copyTimerрядом сsaveTimer,clearTimeoutпри повторных кликах и вonMountcleanup. Устраняет утечку таймеров и phantom-writes в state при unmount. - W-044:
@types/nodeустановлен — устраняет warningCannot find type definition file for 'node'вsvelte-check. Зависимость добавлена вweb/package.jsondevDependencies. - W-045:
manualChunksдляcytoscapeвvite.config.ts— реализован через функцию (Vite 8 не принимает object-formmanualChunks).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.