Skip to Content
ДизайнHot ReloadОбнаружение изменений файла настроек (Issue #3696, подзадача 1)

Обнаружение изменений файла настроек (Issue #3696, подзадача 1)

Контекст

В Qwen Code в настоящее время нет механизма обнаружения изменений файла настроек. Пользователям приходится перезапускать сеанс после изменения settings.json, чтобы изменения вступили в силу. В этом предложении реализуется инфраструктурный уровень для системы горячей перезагрузки #3696 — автоматическое обнаружение и диспетчеризация событий при изменении файлов настроек.

Область: Данная подзадача отвечает только за «обнаружение изменений файла → перезагрузку → уведомление слушателей». Config копирует множество полей настроек во время создания (approvalMode, mcpServers, telemetry и т.д.), и эти снимки НЕ обновляются автоматически данной подзадачей. Только потребители, которые читают LoadedSettings.merged в реальном времени (например, хук useSettings(), disabledSkillNamesProvider), увидят изменения немедленно. Другие подзадачи (переподключение MCP, команда /reload) отвечают за передачу обновлений во внутреннее состояние Config.

Архитектурные решения

Расположение модуля: packages/cli/src/config/settingsWatcher.ts

  • LoadedSettings и пути к файлам настроек находятся в packages/cli
  • reloadScopeFromDisk() — метод LoadedSettings
  • Пакет ядра получает только минимальный интерфейс жизненного цикла { stopWatching(): void }, без импорта типов CLI, таких как SettingScope
  • Диспетчеризация событий изменений и логика обновления нижестоящих модулей целиком реализованы на уровне CLI

Стратегия наблюдения: Наблюдение за родительской директорией + строгая фильтрация по пути

Поток записи writeWithBackupSync выглядит так: write(.tmp) → rename(target, .orig) → rename(.tmp, target) → unlink(.orig), из-за чего целевой файл ненадолго исчезает. Наблюдение за самим файлом привело бы к потере отслеживания chokidar. Поэтому мы наблюдаем за родительской директорией (depth: 0) и фильтруем по точному совпадению имени, реагируя только на события файла settings.json и игнорируя .tmp, .orig, временные файлы редактора и т.д. Резервная копия .orig — это страховочная сетка во время записи и удаляется при успехе (последний шаг unlink), поэтому она никогда не задерживается в директории пользователя.

Отложенная обработка директорий: Никогда не создавать .qwen/ при запуске

Побочный эффект файловой системы при запуске (намеренно избегается). Наблюдатель никогда не должен создавать <project>/.qwen/ (или ~/.qwen/) только для того, чтобы иметь возможность за ним наблюдать. В более ранней версии вызывался mkdirSync({ recursive: true }) для любой отсутствующей директории настроек, что означало, что обычный (не bare) запуск молча создавал <project>/.qwen/ даже в проектах, которые никогда не имели настроек Qwen, — загрязняя рабочее пространство и статус git. Создание директории принадлежит исключительно персистентности настроек (saveSettings() выполняет свой mkdirSync, когда пользователь фактически записывает настройки).

Чтобы по-прежнему обнаруживать settings.json, добавленный позже в сеансе, без создания директории и без рекурсивного обхода дерева проекта, наблюдатель использует двухэтапную стратегию для каждой области, привязанную к существованию директории:

  • .qwen существует при запуске → наблюдаем её напрямую (watchTargetDir, стратегия выше).
  • .qwen отсутствуетначальное наблюдение за родителем (watchParentForDir): chokidar.watch(parentDir, { depth: 0, ignoreInitial: true, ignored }), где предикат ignored (p) => p !== parentDir && basename(p) !== '.qwen' пропускает только запись .qwen. Это подавляет весь несвязанный шум верхнего уровня и никогда не рекурсирует. Как только .qwen появляется, наблюдатель повышает: он закрывает начальный наблюдатель и запускает целевой наблюдатель для .qwen, затем планирует обновление, чтобы подхватить settings.json, который мог уже находиться внутри.

Детали надёжности:

  • Защита TOCTOU: после установки начального наблюдателя (который использует ignoreInitial) повторно проверяется existsSync(dir); если .qwen была создана в промежутке, повышение происходит немедленно.
  • Понижение при удалении: если сама .qwen удаляется (unlinkDir), целевой наблюдатель понижается обратно до начального наблюдателя родителя, чтобы последующее повторное создание было всё ещё обнаружено.
  • Защита поколения: close() в chokidar асинхронен, поэтому устаревший колбэк 'all' от демонтируемого наблюдателя мог бы повторно вызвать повышение и наложить наблюдатели. Монотонный токен поколения для каждой области (увеличивается при каждом повышении/понижении, а также при stopWatching) превращает устаревшие колбэки в no-op, гарантируя не более одного активного наблюдателя на область.

Обнаружение изменений: Семантическая разница как основной механизм дедупликации

Каждый раз, когда срабатывает наблюдатель, он сначала делает снимок текущего состояния в памяти перед перезагрузкой (JSON.stringify(file.settings)), затем вызывает reloadScopeFromDisk() для перезагрузки и, наконец, сравнивает снимки «до» и «после». Слушатели уведомляются только тогда, когда семантическое содержимое действительно изменилось.

Ключевой момент: сравнение происходит между состоянием в памяти до и после перезагрузки, а не с сохранённым историческим снимком. Это связано с тем, что setValue() синхронно обновляет file.settings в памяти перед записью на диск, поэтому, когда срабатывает наблюдатель, состояние в памяти уже содержит записанное значение — перезагрузка даёт тот же контент → нет разницы → нет уведомления.

Это естественным образом подавляет:

  • Дублирующие события от собственных записей (setValue() уже обновила память, перезагрузка даёт идентичный контент → нет разницы → нет уведомления)
  • Изменения только форматирования/комментариев (разрешённые настройки не содержат комментариев)
  • Сохранения редактором без изменения содержимого
  • Дублирующиеся события chokidar

Известное ограничение: JSON.stringify чувствителен к порядку ключей. Если пользователь вручную изменит порядок ключей в settings.json без изменения значений, это вызовет одно безвредное дополнительное уведомление. Это приемлемо; нет необходимости добавлять зависимость для глубокого сравнения.

Реализация

1. Новый класс SettingsWatcher

Файл: packages/cli/src/config/settingsWatcher.ts

export interface SettingsChangeEvent { scope: SettingScope; path: string; changeType: 'modified' | 'created' | 'deleted'; } export type SettingsChangeListener = ( events: SettingsChangeEvent[], ) => void | Promise<void>; export class SettingsWatcher { private readonly settings: LoadedSettings; private readonly watchers: Map<SettingScope, FSWatcher> = new Map(); // 'bootstrap' = наблюдение за родителем для обнаружения `.qwen`; 'target' = наблюдение за `.qwen` private readonly watchStage: Map<SettingScope, 'bootstrap' | 'target'> = new Map(); // Монотонный токен для каждой области; увеличивается при повышении/понижении для аннулирования устаревших колбэков private readonly watchGeneration: Map<SettingScope, number> = new Map(); private readonly changeListeners: Set<SettingsChangeListener> = new Set(); private refreshTimer: NodeJS.Timeout | null = null; private pendingScopeChanges: Set<SettingScope> = new Set(); private processing: boolean = false; // блокировка сериализации private started: boolean = false; static readonly DEBOUNCE_MS = 300; static readonly LISTENER_TIMEOUT_MS = 30_000; }

Основные методы:

startWatching()

  • Проходит по областям User и Workspace
  • Разветвляется в зависимости от существования директории: наблюдать .qwen напрямую, если она существует, иначе — начальное наблюдение за родителем (см. Отложенная обработка директорий)
  • Никогда не создаёт директорию — нет mkdirSync
  • ignoreInitial: true, depth: 0 везде
  • Не вызывается в bare-режиме
startWatching(): void { if (this.started) return; this.started = true; for (const { scope, settingsPath } of this.getScopePaths()) { if (!settingsPath) continue; const dir = path.dirname(settingsPath); // Никогда не создавать директорию; персистентность настроек (saveSettings) владеет этим. if (fs.existsSync(dir)) { this.watchTargetDir(scope, settingsPath); } else { this.watchParentForDir(scope, settingsPath); } } }

watchTargetDir — это наблюдатель родительской директории со строгим совпадением имени, описанный выше (он также понижает обратно до начального наблюдателя, если сама .qwen удалена). watchParentForDir настраивает начальный наблюдатель только для .qwen и повышает, когда .qwen появляется:

private watchParentForDir(scope: SettingScope, settingsPath: string): void { const dir = path.dirname(settingsPath); const parentDir = path.dirname(dir); const dirBasename = path.basename(dir); // ".qwen" const gen = this.bumpGeneration(scope); const watcher = watchFs(parentDir, { ignoreInitial: true, depth: 0, ignored: (filePath: string) => filePath !== parentDir && path.basename(filePath) !== dirBasename, }) .on('all', (_event: string, changedPath: string) => { if (this.watchGeneration.get(scope) !== gen) return; // устаревший колбэк if (path.basename(changedPath) !== dirBasename) return; void this.promoteScope(scope, settingsPath); }) .on('error', (error: unknown) => { debugLogger.warn(`Ошибка начального наблюдателя настроек для ${parentDir}:`, error); }); this.watchers.set(scope, watcher); this.watchStage.set(scope, 'bootstrap'); // Защита TOCTOU: `.qwen` могла появиться между проверкой существования и этим моментом. if (fs.existsSync(dir)) void this.promoteScope(scope, settingsPath); } private async promoteScope(scope: SettingScope, settingsPath: string): Promise<void> { if (this.watchStage.get(scope) !== 'bootstrap') return; // защита от двойного повышения await this.replaceWatcher(scope); // увеличивает поколение + ожидает асинхронного закрытия if (!this.started) return; this.watchTargetDir(scope, settingsPath); this.scheduleRefresh(scope); // подхватить settings.json, уже находящийся внутри .qwen }

stopWatching() — Идемпотентное завершение

stopWatching(): void { if (!this.started) return; this.started = false; for (const [, watcher] of this.watchers) { watcher.close().catch((err) => debugLogger.warn('Ошибка закрытия наблюдателя:', err)); } this.watchers.clear(); if (this.refreshTimer) { clearTimeout(this.refreshTimer); this.refreshTimer = null; } this.pendingScopeChanges.clear(); }

scheduleRefresh(scope) — Дебаунс 300 мс + накопление областей

private scheduleRefresh(scope: SettingScope): void { this.pendingScopeChanges.add(scope); if (this.refreshTimer) clearTimeout(this.refreshTimer); this.refreshTimer = setTimeout(() => { this.refreshTimer = null; void this.drainPendingChanges(); }, SettingsWatcher.DEBOUNCE_MS); }

drainPendingChanges() — Сериализованная обработка для предотвращения повторного входа

private async drainPendingChanges(): Promise<void> { if (this.processing) return; // предыдущий раунд всё ещё выполняется; он завершится при выходе this.processing = true; try { while (this.pendingScopeChanges.size > 0) { const scopes = new Set(this.pendingScopeChanges); this.pendingScopeChanges.clear(); await this.handleChange(scopes); } } finally { this.processing = false; } }

handleChange(scopes) — Перезагрузка + семантическая разница + уведомление

private async handleChange(changedScopes: Set<SettingScope>): Promise<void> { const events: SettingsChangeEvent[] = []; for (const scope of changedScopes) { const file = this.settings.forScope(scope); // Снимок текущего состояния в памяти перед перезагрузкой (включает мутации setValue()) const beforeSettings = JSON.stringify(file.settings); const existedBefore = file.rawJson !== undefined; // reloadScopeFromDisk имеет внутренний try/catch; при ошибке парсинга сохраняет старое состояние this.settings.reloadScopeFromDisk(scope); const afterSettings = JSON.stringify(file.settings); const existsNow = file.rawJson !== undefined; // Семантическая разница: уведомлять только при фактическом изменении содержимого // Подавление собственных записей: setValue() уже обновил память → перезагрузка совпадает → нет уведомления if (afterSettings === beforeSettings) continue; events.push({ scope, path: file.path, changeType: !existedBefore && existsNow ? 'created' : existedBefore && !existsNow ? 'deleted' : 'modified', }); } if (events.length > 0) { await this.notifyListeners(events); } }

notifyListeners(events)Promise.allSettled() + таймаут 30 с

Повторно использует шаблон уведомления слушателей из SkillManager (packages/core/src/skills/skill-manager.ts:188-236): каждый слушатель обёрнут в гонку с таймаутом 30 с, выполняется параллельно через Promise.allSettled, ошибки не распространяются.

addChangeListener(listener) — Возвращает функцию отписки

2. Изменения в LoadedSettings

Файл: packages/cli/src/config/settings.ts

Изменений не требуется. Механизм семантической разницы полностью самодостаточен в наблюдателе. setValue() синхронно обновляет память → saveSettings() записывает на диск → срабатывает наблюдатель → reloadScopeFromDisk() перезагружает → сравнение разницы находит идентичное содержимое → нет уведомления. Цепочка замыкается естественным образом.

3. Интеграция с Config (минимальный интерфейс)

Файл: packages/core/src/config/config.ts

Добавить в ConfigParameters:

/** Дескриптор жизненного цикла для внешнего наблюдателя файлов. Останавливается при завершении. */ settingsWatcher?: { stopWatching(): void };

В Config.shutdown() остановить наблюдателя перед проверкой initialized:

async shutdown(): Promise<void> { try { // Остановить внешнего наблюдателя независимо от состояния инициализации this.settingsWatcher?.stopWatching(); if (!this.initialized) return; // ... остальная логика очистки ... } }

Слушатели изменений настроек в Config не добавляются. Диспетчеризация событий изменений полностью выполняется на уровне CLI, где слушатели напрямую вызывают методы обновления ядра (например, skillManager.refreshCache(), toolRegistry.restartMcpServers()). Это позволяет ядру оставаться в неведении относительно семантики изменений настроек.

4. Инициализация при запуске

Файл: packages/cli/src/gemini.tsx

После loadSettings() и loadCliConfig():

// Создать наблюдатель (пропустить в bare-режиме) const settingsWatcher = isBareMode(argv.bare) ? undefined : new SettingsWatcher(settings); settingsWatcher?.startWatching(); // Передать дескриптор жизненного цикла наблюдателя при загрузке конфигурации CLI const config = await loadCliConfig(settings.merged, argv, ..., { settingsWatcher, }); // Зарегистрировать слушатель изменений (будущие подзадачи добавят сюда фактическую логику обновления) settingsWatcher?.addChangeListener(async (events) => { debugLogger.info('Настройки изменены:', events.map(e => `${e.scope}:${e.changeType}`)); // Подзадачи 2–6 добавят: // - skillManager.refreshCache() // - toolRegistry.restartMcpServers() // - clearAllCaches() // - флаг needsRefresh });

Изменение сигнатуры loadCliConfig (packages/cli/src/config/config.ts): Добавить необязательный параметр для передачи settingsWatcher в ConfigParameters.

Обработка граничных случаев

СценарийОбработка
Директория .qwen не существуетНикогда не создаётся. Начальное наблюдение за родителем (depth: 0, фильтр только .qwen), повышение при появлении .qwen
.qwen создана после запускаНачальный наблюдатель ловит addDir, повышает до целевого наблюдателя + планирует обновление
.qwen удалена после повышенияЦелевой наблюдатель ловит unlinkDir → понижает обратно до начального наблюдателя родителя
Файл удалёнreloadScopeFromDisk обнаруживает !existsSync, сбрасывает в {}, разница вызывает событие deleted
Файл создан после запуска (директория есть)Наблюдатель директории ловит событие add, reloadScopeFromDisk читает новый файл
Устаревший колбэк во время повышения/пониженияТокен поколения для каждой области делает текущий колбэк закрываемого наблюдателя no-op (нет наложения наблюдателей)
Атомарная запись редакторомНаблюдение за директорией + строгая фильтрация по имени (исключает .tmp/.orig) + объединение с дебаунсом 300 мс
События файлов .tmp/.origФильтр по имени точно совпадает с settings.json, все остальные имена игнорируются
Собственная запись (setValuesaveSettings)Семантическая разница: содержимое перезагрузки совпадает со снимком в памяти → нет уведомления
Собственная запись параллельно с внешним редактированиемВнешнее редактирование меняет содержимое → разница обнаруживает изменение → корректно уведомляет
Изменения только форматирования/комментариевreloadScopeFromDisk разрешает настройки без комментариев → разница совпадает → нет уведомления
Дублирующиеся события chokidarОбъединение с дебаунсом + семантическая разница обеспечивают двойную защиту
Перенаправление QWEN_HOMEgetUserSettingsPath() уже разрешает путь; наблюдатель использует разрешённый путь
Bare-режимstartWatching() никогда не вызывается, нулевые накладные расходы
Ошибка создания наблюдателяИсключение перехватывается, логируется предупреждение; для этой области нет обнаружения в реальном времени, но функциональность не нарушена
Ошибка парсинга reloadScopeFromDiskВнутренний try/catch (settings.ts:501) сохраняет старое состояние → разница до/после совпадает → нет уведомления
Изменение порядка ключей (без изменения значений)JSON.stringify чувствителен к порядку ключей; может вызвать одно безвредное дополнительное уведомление
Ошибка инициализации Configshutdown() останавливает наблюдатель перед проверкой initialized, предотвращая утечки
Повторный вход (слушатель всё ещё выполняется)Флаг processing + цикл drainPendingChanges сериализует обработку
Некорректный JSONВнутренний try/catch reloadScopeFromDisk сохраняет старое состояние

Анализ производительности

  • Не более 1 наблюдателя на область (≤ 2 всего), каждый на depth: 0 — минимальные накладные расходы на файловые дескрипторы; повышение/понижение меняют наблюдателей, а не накапливают их
  • depth: 0 означает отсутствие рекурсивного обхода дерева проекта, даже для начального наблюдателя родителя в большом монрепо. Затраты ограничены прямыми потомками родительской директории: несвязанный шум верхнего уровня пробуждает chokidar для одного прохода readdir + фильтр ignored (O(количество записей верхнего уровня)) до подавления события — никогда не бывает рекурсивного сканирования
  • Дебаунс 300 мс гарантирует, что быстрые сохранения редактора не вызовут множественные перезагрузки
  • reloadScopeFromDisk использует синхронный readFileSync, < 1 мс на вызов
  • Сравнение JSON.stringify имеет сложность O(n), но объекты настроек обычно < 10 КБ; дополнительное хранилище снимков не требуется
  • Уведомление слушателей выполняется параллельно через Promise.allSettled
  • Нет опроса — чисто событийно-ориентированный подход

Файлы для создания/изменения

Новые файлы:

  • packages/cli/src/config/settingsWatcher.ts — класс наблюдателя
  • packages/cli/src/config/settingsWatcher.test.ts — модульные тесты

Изменённые файлы:

  • packages/core/src/config/config.ts — добавить поле settingsWatcher в ConfigParameters, вызвать stopWatching() перед проверкой initialized в Config.shutdown()
  • packages/cli/src/config/config.ts (loadCliConfig) — добавить необязательный параметр для передачи settingsWatcher
  • packages/cli/src/gemini.tsx — создание наблюдателя + интеграция

Модификации не требуются: packages/cli/src/config/settings.ts (семантическая разница самодостаточна и не требует взаимодействия с LoadedSettings)

План тестирования

Модульные тесты (settingsWatcher.test.ts)

Мок chokidar (используя шаблон моков из skill-manager.test.ts):

  1. Жизненный цикл: startWatching создаёт наблюдателей, stopWatching закрывает наблюдателей, оба идемпотентны.
  2. Фильтрация путей: Только события для базового имени settings.json вызывают обновление; файлы .tmp/.orig/другие игнорируются.
  3. Устранение дребезга: Множественные быстрые события объединяются в одну перезагрузку (vi.useFakeTimers()).
  4. Семантическая разница: неизменённое содержимое → слушатель не вызывается; изменённое содержимое → слушатель вызывается с корректными событиями.
  5. Подавление записи от себя: события наблюдателя, вызванные setValue(), естественным образом отфильтровываются из-за одинаковой разницы.
  6. Сериализация: новые события во время handleChange накапливаются и обрабатываются после завершения текущей обработки.
  7. Изоляция ошибок: ошибки chokidar не приводят к краху; исключения в слушателях не влияют на другие слушатели; ошибки reloadScopeFromDisk перехватываются.
  8. Тайм-аут слушателя: защита тайм-аутом в 30 секунд.
  9. Ленивое наблюдение за директорией: когда .qwen отсутствует, mkdirSync никогда не вызывается; на родительской директории устанавливается начальный наблюдатель (bootstrap watcher), и его предикат ignored пропускает только запись .qwen.
  10. Повышение / TOCTOU: появление .qwen (через addDir или последующую проверку после установки) закрывает начального наблюдателя и открывает целевого наблюдателя на .qwen + планирует обновление.
  11. Понижение / повторное создание: удаление .qwen (unlinkDir) повторно инициализирует начального наблюдателя на родителе; последующее повторное создание снова повышает.
  12. Защита поколений: устаревший callback от уже закрытого начального наблюдателя не создаёт второго целевого наблюдателя.

Проверка регрессии

cd packages/cli && npx tsc --noEmit cd packages/core && npx tsc --noEmit cd packages/cli && npx vitest run src/config/ cd packages/core && npx vitest run src/config/

Ручная проверка

Отредактируйте ~/.qwen/settings.json во время работающего сеанса и наблюдайте вывод отладочного журнала для событий изменения.


Последующая подзадача: подавление событий для настроек, требующих перезапуска, и чувствительных настроек

Статус: механизм подавления реализован; всё ещё ожидается исследование двух изменений схемы. Подзадача 1 выше генерировала одно событие SettingsChangeEvent для каждой области при любом семантическом изменении. Эта последующая подзадача добавляет фильтр, чтобы изменения, ограниченные настройками, которые не могут вступить в силу без перезапуска — или которые являются чувствительными (учётные данные) — не уведомляли слушателей.

  • Выполнено: механизм подавления на основе requiresRestart в SettingsWatcher.handleChange() плюс модульные тесты (см. Механизм ниже).
  • Ожидается: две коррекции схемы requiresRestart (modelProviderstrue, permissions.* → оставить hot-reloadable), каждая требует проверки пути чтения во время выполнения.

Мотивация

Некоторые настройки считываются ровно один раз во время запуска процесса (Config.initialize(), построение генератора контента/клиента, порождение дочерних процессов, флаги Node runtime). Примеры, которые пользователь явно указал: токены API, env и провайдеры моделей. Генерация события hot-reload для таких настроек вводит в заблуждение — слушатель “обновится”, но новое значение на самом деле не применится до тех пор, пока пользователь не перезапустит qwen-code. Чувствительные значения (учётные данные) дополнительно не должны повторно использоваться в работающем сеансе.

Решение: повторно использовать флаг requiresRestart из схемы (единый источник истины)

settingsSchema.ts уже объявляет requiresRestart: boolean для каждого ключа, а packages/cli/src/utils/settingsUtils.ts уже предоставляет функции поиска:

  • requiresRestart(key: string): boolean — флаг для ключа в dot-нотации
  • getFlattenedSchema() — полная свёрнутая карта ключ → определение
  • getRestartRequiredSettings() — все ключи с requiresRestart: true

Мы повторно используем этот флаг как сигнал подавления вместо поддержки отдельного ручного списка запрещённых ключей (который неизбежно расходился бы со схемой). requiresRestart: true уже означает именно “не вступит в силу без перезапуска”, что как раз и является условием для подавления события.

Механизм (реализован в SettingsWatcher.handleChange())

Старый фильтр выполнял сравнение всего файла через JSON.stringify и не мог определить, какие ключи изменились. Он заменяется на сравнение на уровне листьев + классификацию по ключам:

  1. collectChangedKeys(before, after) делает снимок состояния в памяти до перезагрузки (structuredClone), затем обходит before/after и собирает dot-путь каждого листа, значение которого отличается. Простые объекты обходятся рекурсивно; массивы и примитивы сравниваются целиком (соответствует ключам-массивам в схеме, например permissions.allow). Добавленные/удалённые ключи фиксируются как изменённые листья, поэтому создание/удаление файла обрабатывается без отдельной проверки существования.
  2. isRestartRequiredKey(path) сопоставляет каждый изменённый путь со схемой, используя самый длинный ключ схемы, являющийся префиксом (или равным) пути. Настройки со свободной формой объекта (env, modelProviders) являются листовыми ключами схемы, поэтому env.FOO разрешается в определение env. Неизвестные ключи по умолчанию не требуют перезапуска, так что изменение, которое мы не можем классифицировать, никогда не будет молча подавлено.
  3. Область уведомляет только если хотя бы один изменённый ключ является hot-reloadable (!isRestartRequiredKey). Если каждый изменённый ключ требует перезапуска, область не генерирует событие.

Форма SettingsChangeEvent не изменилась (остаётся { scope, path, changeType }); вынос выживших изменённых ключей в событие оставлен как возможное будущее улучшение. Подавление записи от себя (пустая разница → нет события), устранение дребезга, сериализация и поведение тайм-аута слушателя остались без изменений.

Два изменения схемы для исследования и применения

Эти два значения requiresRestart необходимо исправить, чтобы подход с повторным использованием работал так, как задумано. Каждое требует проверки фактического пути чтения во время выполнения перед изменением флага.

  1. modelProviders: falsetrue (settingsSchema.ts:294)

    • Сегодня он помечен как requiresRestart: false, поэтому при повторном использовании он бы не подавлялся — что противоречит требованию, чтобы изменения провайдера не применялись через hot-reload.
    • Конфигурация провайдера (включая apiKey / baseUrl для каждого провайдера) используется при построении клиента модели / генератора контента во время запуска.
    • Пункт исследования: подтвердить отсутствие повторного чтения modelProviders во время выполнения (поискать в построении генератора контента / клиента). Ожидаемый результат: false — скрытая ошибка; изменить на true.
  2. permissions.*: оставить hot-reloadable (settingsSchema.ts:1560, всё поддерево в настоящее время requiresRestart: true)

    • Правила разрешений (deny > ask > allow) вычисляются при каждом вызове инструмента и предназначены для того, чтобы пользователи хотели, чтобы они вступали в силу немедленно.
    • Всё поддерево permissions имеет showInDialog: false, поэтому его флаг requiresRestart в настоящее время не имеет значения в UI — сильный намёк на то, что true было значением по умолчанию, а не намеренным решением “требует перезапуска”, так что радиус поражения при его изменении мал.
    • Пункт исследования: подтвердить, что среда выполнения перечитывает права доступа в реальном времени (например, через config.getXxx() в момент оценки), а не использует снимок при запуске. Если подтверждено, установите поддереву permissions значение requiresRestart: false, чтобы оно не подавлялось механизмом повторного использования.

Примечание: поскольку requiresRestart также отображается в UI настроек / запросах на перезапуск, изменение этих флагов также меняет это поведение. Это допустимо и, вероятно, более корректно, но должно быть отмечено в описании PR.

Критерии приёмки

  • Изменение, затрагивающее только ключи, требующие перезапуска/чувствительные ключи (security.auth.*, env, modelProviders, mcpServers, proxy, …), не генерирует ни одного SettingsChangeEvent.
  • Изменение hot-reloadable ключа (ui.*, model.name, permissions.* после изменения, …) по-прежнему генерирует событие.
  • Смешанное изменение (один ключ, требующий перезапуска + один hot-reloadable ключ) по-прежнему генерирует событие (hot-reloadable часть легитимно нуждается в обновлении).
  • Изменение неизвестного (не из схемы) ключа по-прежнему генерирует событие, а не молча подавляется.

Статус тестов:

  • Выполнено — блок restart-required suppression в settingsWatcher.test.ts покрывает случаи: все подавлены (env, security.auth.apiKey), все разрешены (ui.theme), смешанные и неизвестные ключи.
  • Ожидается (вместе с изменениями схемы) — утверждения в settingsSchema.test.ts, фиксирующие два исправленных значения requiresRestart, и тест наблюдателя, проверяющий, что permissions.* больше не подавляется после изменения.
Last updated on