Обнаружение изменений файла настроек (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/clireloadScopeFromDisk()— метод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, все остальные имена игнорируются |
Собственная запись (setValue → saveSettings) | Семантическая разница: содержимое перезагрузки совпадает со снимком в памяти → нет уведомления |
| Собственная запись параллельно с внешним редактированием | Внешнее редактирование меняет содержимое → разница обнаруживает изменение → корректно уведомляет |
| Изменения только форматирования/комментариев | reloadScopeFromDisk разрешает настройки без комментариев → разница совпадает → нет уведомления |
| Дублирующиеся события chokidar | Объединение с дебаунсом + семантическая разница обеспечивают двойную защиту |
Перенаправление QWEN_HOME | getUserSettingsPath() уже разрешает путь; наблюдатель использует разрешённый путь |
| Bare-режим | startWatching() никогда не вызывается, нулевые накладные расходы |
| Ошибка создания наблюдателя | Исключение перехватывается, логируется предупреждение; для этой области нет обнаружения в реальном времени, но функциональность не нарушена |
Ошибка парсинга reloadScopeFromDisk | Внутренний try/catch (settings.ts:501) сохраняет старое состояние → разница до/после совпадает → нет уведомления |
| Изменение порядка ключей (без изменения значений) | JSON.stringify чувствителен к порядку ключей; может вызвать одно безвредное дополнительное уведомление |
| Ошибка инициализации Config | shutdown() останавливает наблюдатель перед проверкой 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) — добавить необязательный параметр для передачиsettingsWatcherpackages/cli/src/gemini.tsx— создание наблюдателя + интеграция
Модификации не требуются: packages/cli/src/config/settings.ts (семантическая разница самодостаточна и не требует взаимодействия с LoadedSettings)
План тестирования
Модульные тесты (settingsWatcher.test.ts)
Мок chokidar (используя шаблон моков из skill-manager.test.ts):
- Жизненный цикл:
startWatchingсоздаёт наблюдателей,stopWatchingзакрывает наблюдателей, оба идемпотентны. - Фильтрация путей: Только события для базового имени
settings.jsonвызывают обновление; файлы.tmp/.orig/другие игнорируются. - Устранение дребезга: Множественные быстрые события объединяются в одну перезагрузку (
vi.useFakeTimers()). - Семантическая разница: неизменённое содержимое → слушатель не вызывается; изменённое содержимое → слушатель вызывается с корректными событиями.
- Подавление записи от себя: события наблюдателя, вызванные
setValue(), естественным образом отфильтровываются из-за одинаковой разницы. - Сериализация: новые события во время
handleChangeнакапливаются и обрабатываются после завершения текущей обработки. - Изоляция ошибок: ошибки chokidar не приводят к краху; исключения в слушателях не влияют на другие слушатели; ошибки
reloadScopeFromDiskперехватываются. - Тайм-аут слушателя: защита тайм-аутом в 30 секунд.
- Ленивое наблюдение за директорией: когда
.qwenотсутствует,mkdirSyncникогда не вызывается; на родительской директории устанавливается начальный наблюдатель (bootstrap watcher), и его предикатignoredпропускает только запись.qwen. - Повышение / TOCTOU: появление
.qwen(черезaddDirили последующую проверку после установки) закрывает начального наблюдателя и открывает целевого наблюдателя на.qwen+ планирует обновление. - Понижение / повторное создание: удаление
.qwen(unlinkDir) повторно инициализирует начального наблюдателя на родителе; последующее повторное создание снова повышает. - Защита поколений: устаревший 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(modelProviders→true,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 и не мог определить, какие ключи изменились. Он заменяется на сравнение на уровне листьев + классификацию по ключам:
collectChangedKeys(before, after)делает снимок состояния в памяти до перезагрузки (structuredClone), затем обходит before/after и собирает dot-путь каждого листа, значение которого отличается. Простые объекты обходятся рекурсивно; массивы и примитивы сравниваются целиком (соответствует ключам-массивам в схеме, напримерpermissions.allow). Добавленные/удалённые ключи фиксируются как изменённые листья, поэтому создание/удаление файла обрабатывается без отдельной проверки существования.isRestartRequiredKey(path)сопоставляет каждый изменённый путь со схемой, используя самый длинный ключ схемы, являющийся префиксом (или равным) пути. Настройки со свободной формой объекта (env,modelProviders) являются листовыми ключами схемы, поэтомуenv.FOOразрешается в определениеenv. Неизвестные ключи по умолчанию не требуют перезапуска, так что изменение, которое мы не можем классифицировать, никогда не будет молча подавлено.- Область уведомляет только если хотя бы один изменённый ключ является hot-reloadable (
!isRestartRequiredKey). Если каждый изменённый ключ требует перезапуска, область не генерирует событие.
Форма SettingsChangeEvent не изменилась (остаётся { scope, path, changeType }); вынос выживших изменённых ключей в событие оставлен как возможное будущее улучшение. Подавление записи от себя (пустая разница → нет события), устранение дребезга, сериализация и поведение тайм-аута слушателя остались без изменений.
Два изменения схемы для исследования и применения
Эти два значения requiresRestart необходимо исправить, чтобы подход с повторным использованием работал так, как задумано. Каждое требует проверки фактического пути чтения во время выполнения перед изменением флага.
-
modelProviders:false→true(settingsSchema.ts:294)- Сегодня он помечен как
requiresRestart: false, поэтому при повторном использовании он бы не подавлялся — что противоречит требованию, чтобы изменения провайдера не применялись через hot-reload. - Конфигурация провайдера (включая
apiKey/baseUrlдля каждого провайдера) используется при построении клиента модели / генератора контента во время запуска. - Пункт исследования: подтвердить отсутствие повторного чтения
modelProvidersво время выполнения (поискать в построении генератора контента / клиента). Ожидаемый результат:false— скрытая ошибка; изменить наtrue.
- Сегодня он помечен как
-
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.*больше не подавляется после изменения.