Détection des modifications du fichier de paramètres (sous-tâche 1 de l’issue #3696)
Contexte
Qwen Code ne dispose actuellement d’aucun mécanisme de détection des modifications du fichier de paramètres. Les utilisateurs doivent redémarrer la session après avoir modifié settings.json pour que les changements soient pris en compte. Cette proposition implémente la couche d’infrastructure pour le système de rechargement à chaud #3696 : détection automatique et distribution d’événements pour les modifications du fichier de paramètres.
Périmètre : Cette sous-tâche est uniquement responsable de “détecter les changements de fichier → recharger → notifier les auditeurs”. Config copie de nombreux champs de configuration au moment de la construction (approvalMode, mcpServers, telemetry, etc.), et ces instantanés ne sont PAS automatiquement mis à jour par cette sous-tâche. Seuls les consommateurs qui lisent LoadedSettings.merged en temps réel (par ex., le hook useSettings(), disabledSkillNamesProvider) verront immédiatement les changements. Les autres sous-tâches (reconnexion MCP, commande /reload) sont responsables de la mise à jour de l’état interne de Config.
Décisions architecturales
Emplacement du module : packages/cli/src/config/settingsWatcher.ts
LoadedSettingset les chemins des fichiers de paramètres se trouvent tous deux danspackages/clireloadScopeFromDisk()est une méthode deLoadedSettings- Le package core ne reçoit qu’une interface de cycle de vie minimale
{ stopWatching(): void }, sans importer les types CLI commeSettingScope - La distribution des événements de changement et la logique de rafraîchissement en aval sont entièrement câblées dans la couche CLI
Stratégie de surveillance : Surveiller le répertoire parent + filtrage strict par chemin
Le flux d’écriture de writeWithBackupSync est write(.tmp) → rename(target, .orig) → rename(.tmp, target) → unlink(.orig), ce qui fait que le fichier cible disparaît brièvement. Surveiller directement le chemin du fichier ferait perdre la surveillance à chokidar. Par conséquent, nous surveillons le répertoire parent (depth: 0) et filtrons par correspondance exacte du nom de base, en ne répondant qu’aux événements de fichier settings.json et en ignorant .tmp, .orig, les fichiers temporaires de l’éditeur, etc. La sauvegarde .orig est un filet de sécurité temporaire et est supprimée en cas de succès (étape finale unlink), donc elle ne persiste jamais dans le répertoire de l’utilisateur.
Gestion paresseuse des répertoires : Ne jamais créer .qwen/ au démarrage
Effet secondaire sur le système de fichiers au démarrage (volontairement évité). Le surveillant ne doit jamais créer
<projet>/.qwen/(ou~/.qwen/) juste pour pouvoir le surveiller. Une version antérieure appelaitmkdirSync({ recursive: true })pour tout répertoire de paramètres manquant, ce qui signifiait qu’un démarrage normal non-bare créait silencieusement<projet>/.qwen/même dans des projets qui n’avaient jamais eu de paramètres Qwen — polluant l’espace de travail et l’état git. La création de répertoire appartient uniquement à la persistance des paramètres (saveSettings()effectue son propremkdirSynclorsque l’utilisateur écrit effectivement des paramètres).
Pour toujours détecter un settings.json ajouté ultérieurement dans la session sans créer le répertoire et sans parcourir l’arborescence du projet, le surveillant utilise une stratégie en deux étapes, par portée, basée sur l’existence du répertoire :
.qwenexiste au démarrage → le surveiller directement (watchTargetDir, la stratégie ci-dessus)..qwenmanquant → surveiller le parent en amorçage (watchParentForDir) :chokidar.watch(parentDir, { depth: 0, ignoreInitial: true, ignored })où le prédicatignored(p) => p !== parentDir && basename(p) !== '.qwen'ne laisse passer que l’entrée.qwen. Cela supprime toute agitation non liée au niveau racine et ne parcourt jamais l’arborescence. Une fois.qwenapparu, le surveillant promeut : il ferme le surveillant d’amorçage et démarre un surveillant cible sur.qwen, puis planifie un rafraîchissement pour récupérer unsettings.jsonqui pourrait déjà se trouver à l’intérieur.
Détails de robustesse :
- Protection TOCTOU : après avoir armé le surveillant d’amorçage (qui utilise
ignoreInitial),existsSync(dir)est revérifié ; si.qwena été créé dans l’intervalle, la promotion a lieu immédiatement. - Rétrogradation en cas de suppression : si
.qwenlui-même est supprimé (unlinkDir), le surveillant cible rétrograde vers un surveillant d’amorçage parent afin qu’une future recréation soit toujours détectée. - Protection de génération :
close()de chokidar est asynchrone, donc un callback'all'obsolète provenant d’un surveillant en cours de démantèlement pourrait autrement redéclencher la promotion et empiler les surveillants. Un jeton de génération monotone par portée (incrémenté à chaque promotion/rétrogradation et àstopWatching) rend les callbacks obsolètes inopérants, garantissant au plus un surveillant actif par portée.
Détection des modifications : Diff sémantique comme mécanisme principal de déduplication
Chaque fois que le surveillant se déclenche, il prend d’abord un instantané de l’état en mémoire actuel avant le rechargement (JSON.stringify(file.settings)), puis appelle reloadScopeFromDisk() pour recharger, et enfin compare les instantanés avant/après. Les auditeurs ne sont notifiés que lorsque le contenu sémantique a effectivement changé.
Point clé : la comparaison se fait entre l’état en mémoire avant et après le rechargement, et non par rapport à un instantané historique stocké. En effet, setValue() met à jour de manière synchrone file.settings en mémoire avant d’écrire sur le disque, donc lorsque le surveillant déclenche un rechargement, l’état en mémoire contient déjà la valeur auto-écrite — le rechargement produit le même contenu → pas de diff → pas de notification.
Cela supprime naturellement :
- Les événements en double provenant des auto-écritures (
setValue()a déjà mis à jour la mémoire, le rechargement produit un contenu identique → pas de diff → pas de notification) - Les modifications uniquement de format/commentaires (les paramètres résolus n’incluent pas les commentaires)
- Les sauvegardes d’éditeur sans modification du contenu
- Les événements chokidar en double
Limitation connue : JSON.stringify est sensible à l’ordre des clés. Si un utilisateur réorganise manuellement les clés dans settings.json sans changer les valeurs, cela déclenchera une notification supplémentaire inoffensive. Cela est acceptable ; il n’est pas nécessaire d’introduire une dépendance de comparaison profonde.
Implémentation
1. Nouvelle classe SettingsWatcher
Fichier : 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' = surveille le parent pour `.qwen` ; 'target' = surveille `.qwen`
private readonly watchStage: Map<SettingScope, 'bootstrap' | 'target'> =
new Map();
// Jeton monotone par portée ; incrémenté en cas de promotion/rétrogradation pour annuler les callbacks obsolètes
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; // garde de sérialisation
private started: boolean = false;
static readonly DEBOUNCE_MS = 300;
static readonly LISTENER_TIMEOUT_MS = 30_000;
}Méthodes principales :
startWatching()
- Parcourt les portées Utilisateur et Espace de travail
- Se base sur l’existence du répertoire : surveille
.qwendirectement s’il existe, sinon surveille le parent en amorçage (voir Gestion paresseuse des répertoires) - Ne crée jamais le répertoire — pas de
mkdirSync ignoreInitial: true,depth: 0partout- N’est pas appelée en mode 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);
// Ne jamais créer le répertoire ; la persistance des paramètres (saveSettings) en est propriétaire.
if (fs.existsSync(dir)) {
this.watchTargetDir(scope, settingsPath);
} else {
this.watchParentForDir(scope, settingsPath);
}
}
}watchTargetDir est le surveillant de répertoire parent + nom de base strict décrit ci-dessus (il rétrograde également vers un surveillant d’amorçage si .qwen lui-même est supprimé). watchParentForDir arme le surveillant d’amorçage dédié à .qwen et promeut une fois .qwen apparu :
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; // callback obsolète
if (path.basename(changedPath) !== dirBasename) return;
void this.promoteScope(scope, settingsPath);
})
.on('error', (error: unknown) => {
debugLogger.warn(`Erreur du surveillant d'amorçage des paramètres pour ${parentDir} :`, error);
});
this.watchers.set(scope, watcher);
this.watchStage.set(scope, 'bootstrap');
// Protection TOCTOU : `.qwen` a pu apparaître entre la vérification d'existence et maintenant.
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; // protège contre la double promotion
await this.replaceWatcher(scope); // incrémente la génération + attend la fermeture asynchrone
if (!this.started) return;
this.watchTargetDir(scope, settingsPath);
this.scheduleRefresh(scope); // récupère un settings.json déjà présent dans .qwen
}stopWatching() — Arrêt idempotent
stopWatching(): void {
if (!this.started) return;
this.started = false;
for (const [, watcher] of this.watchers) {
watcher.close().catch((err) => debugLogger.warn('Erreur de fermeture du surveillant :', err));
}
this.watchers.clear();
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
this.refreshTimer = null;
}
this.pendingScopeChanges.clear();
}scheduleRefresh(scope) — Debounce de 300 ms + accumulation des portées
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() — Traitement sérialisé pour éviter la réentrance
private async drainPendingChanges(): Promise<void> {
if (this.processing) return; // le tour précédent est encore en cours ; il se videra en sortant
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) — Rechargement + diff sémantique + notification
private async handleChange(changedScopes: Set<SettingScope>): Promise<void> {
const events: SettingsChangeEvent[] = [];
for (const scope of changedScopes) {
const file = this.settings.forScope(scope);
// Instantané de l'état en mémoire actuel avant le rechargement (inclut les mutations de setValue())
const beforeSettings = JSON.stringify(file.settings);
const existedBefore = file.rawJson !== undefined;
// reloadScopeFromDisk a un try/catch interne ; en cas d'échec d'analyse, il préserve l'ancien état
this.settings.reloadScopeFromDisk(scope);
const afterSettings = JSON.stringify(file.settings);
const existsNow = file.rawJson !== undefined;
// Diff sémantique : ne notifier que lorsque le contenu a effectivement changé
// Suppression d'auto-écriture : setValue() a déjà mis à jour la mémoire → le rechargement correspond → pas de notification
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() + délai de 30 s
Réutilise le modèle de notification des auditeurs de SkillManager (packages/core/src/skills/skill-manager.ts:188-236) : chaque auditeur est enveloppé dans une course contre un délai de 30 s, exécuté en parallèle via Promise.allSettled, les échecs ne se propagent pas.
addChangeListener(listener) — Retourne une fonction de désabonnement
2. Modifications de LoadedSettings
Fichier : packages/cli/src/config/settings.ts
Aucune modification nécessaire. Le mécanisme de diff sémantique est entièrement autonome dans le surveillant. setValue() met à jour la mémoire de manière synchrone → saveSettings() écrit sur le disque → le surveillant se déclenche → reloadScopeFromDisk() recharge → la comparaison de diff trouve un contenu identique → pas de notification. La chaîne se ferme naturellement.
3. Intégration dans Config (interface minimale)
Fichier : packages/core/src/config/config.ts
Ajouter à ConfigParameters :
/** Gestionnaire de cycle de vie pour un surveillant de fichier externe. Arrêté lors de l'arrêt. */
settingsWatcher?: { stopWatching(): void };Dans Config.shutdown(), arrêter le surveillant avant la vérification de initialized :
async shutdown(): Promise<void> {
try {
// Arrêter le surveillant externe, quel que soit l'état d'initialisation
this.settingsWatcher?.stopWatching();
if (!this.initialized) return;
// ... reste de la logique de nettoyage ...
}
}Aucun settingsChangeListeners n’est ajouté à Config. La distribution des événements de changement est entièrement gérée dans la couche CLI, où les auditeurs appellent directement les méthodes de rafraîchissement du core (par ex., skillManager.refreshCache(), toolRegistry.restartMcpServers()). Cela permet au core de rester ignorant de la sémantique des modifications de paramètres.
4. Câblage au démarrage
Fichier : packages/cli/src/gemini.tsx
Après loadSettings() et loadCliConfig() :
// Créer le surveillant (ignorer en mode bare)
const settingsWatcher = isBareMode(argv.bare) ? undefined : new SettingsWatcher(settings);
settingsWatcher?.startWatching();
// Passer le gestionnaire de cycle de vie du surveillant lors du chargement de la config CLI
const config = await loadCliConfig(settings.merged, argv, ..., {
settingsWatcher,
});
// Enregistrer l'auditeur de changement (les futures sous-tâches ajouteront ici la logique de rafraîchissement réelle)
settingsWatcher?.addChangeListener(async (events) => {
debugLogger.info('Paramètres modifiés :', events.map(e => `${e.scope}:${e.changeType}`));
// Les sous-tâches 2-6 ajouteront :
// - skillManager.refreshCache()
// - toolRegistry.restartMcpServers()
// - clearAllCaches()
// - drapeau needsRefresh
});Modification de la signature de loadCliConfig (packages/cli/src/config/config.ts) : Ajouter un paramètre optionnel pour passer settingsWatcher à ConfigParameters.
Gestion des cas limites
| Scénario | Gestion |
|---|---|
Le répertoire .qwen n’existe pas | Jamais créé. Surveillance d’amorçage du parent (depth: 0, filtre uniquement .qwen), promotion dès que .qwen apparaît |
.qwen créé après le démarrage | Le surveillant d’amorçage détecte addDir, promeut vers un surveillant cible + planifie un rafraîchissement |
.qwen supprimé après promotion | Le surveillant cible détecte unlinkDir → rétrograde vers un surveillant d’amorçage parent |
| Fichier supprimé | reloadScopeFromDisk détecte !existsSync, réinitialise à {}, le diff déclenche un événement deleted |
| Fichier créé après le démarrage (répertoire existant) | Le surveillant de répertoire détecte l’événement add, reloadScopeFromDisk lit le nouveau fichier |
| Callback obsolète durant promotion/rétrogradation | Le jeton de génération par portée rend le callback en vol du surveillant en cours de fermeture inopérant (pas d’empilement de surveillants) |
| Écritures atomiques de l’éditeur | Surveillance de répertoire + filtrage strict du nom de base (exclut .tmp/.orig) + debounce de 300 ms |
Événements de fichiers .tmp/.orig | Le filtre de nom de base correspond exactement à settings.json, tous les autres noms de fichiers sont ignorés |
Auto-écriture (setValue → saveSettings) | Diff sémantique : le contenu rechargé correspond à l’instantané en mémoire → pas de notification |
| Auto-écriture concurrente avec modification externe | La modification externe change le contenu → le diff détecte le changement → notification correcte |
| Modifications uniquement de format/commentaires | reloadScopeFromDisk résout les paramètres sans commentaires → le diff correspond → pas de notification |
| Événements chokidar en double | Le debounce + le diff sémantique offrent une double protection |
Redirection QWEN_HOME | getUserSettingsPath() résout déjà le chemin ; le surveillant utilise le chemin résolu |
| Mode bare | startWatching() n’est jamais appelée, surcharge nulle |
| Échec de création du surveillant | Exception capturée, avertissement journalisé, cette portée n’a pas de détection en temps réel mais la fonctionnalité n’est pas affectée |
Échec d’analyse de reloadScopeFromDisk | try/catch interne (settings.ts:501) préserve l’ancien état → le diff avant/après correspond → pas de notification |
| Changement d’ordre des clés (pas de changement de valeur) | JSON.stringify est sensible à l’ordre des clés ; peut produire une notification supplémentaire inoffensive |
| Échec d’initialisation de Config | shutdown() arrête le surveillant avant la vérification de initialized, évitant les fuites |
| Réentrance (auditeur encore en cours) | Le drapeau processing + la boucle drainPendingChanges sérialisent le traitement |
| JSON invalide | try/catch interne de reloadScopeFromDisk préserve l’ancien état |
Analyse des performances
- Au plus 1 surveillant par portée (≤ 2 au total), chacun à
depth: 0— surcharge minimale en descripteurs de fichier ; la promotion/rétrogradation échange les surveillants, ne les empile jamais depth: 0signifie aucun parcours récursif de l’arbre du projet, même pour le surveillant d’amorçage parent dans un grand monorepo. Le coût est limité aux enfants directs du répertoire parent : l’agitation non liée au niveau racine réveille chokidar pour unreaddir+ un passage de filtreignored(O(entrées de premier niveau)) avant que l’événement ne soit supprimé — jamais une analyse récursive- Le debounce de 300 ms garantit que les sauvegardes rapides de l’éditeur ne déclenchent pas plusieurs rechargements
reloadScopeFromDiskutilisereadFileSyncsynchrone, < 1 ms par appel- La comparaison
JSON.stringifyest en O(n) mais les objets de paramètres font généralement < 10 Ko ; aucun stockage d’instantané supplémentaire nécessaire - La notification des auditeurs s’exécute en parallèle via
Promise.allSettled - Pas de scrutation — purement basé sur les événements
Fichiers à créer/modifier
Nouveaux fichiers :
packages/cli/src/config/settingsWatcher.ts— classe de surveillancepackages/cli/src/config/settingsWatcher.test.ts— tests unitaires
Fichiers modifiés :
packages/core/src/config/config.ts— ajouter le champsettingsWatcheràConfigParameters, appelerstopWatching()avant la vérification deinitializeddansConfig.shutdown()packages/cli/src/config/config.ts(loadCliConfig) — ajouter un paramètre optionnel pour passersettingsWatcherpackages/cli/src/gemini.tsx— instancier le surveillant + câblage
Aucune modification nécessaire : packages/cli/src/config/settings.ts (le diff sémantique est autonome et ne nécessite aucune coopération de LoadedSettings)
Plan de test
Tests unitaires (settingsWatcher.test.ts)
Mock chokidar (en réutilisant le même modèle de mock que skill-manager.test.ts) :
- Cycle de vie :
startWatchingcrée les watchers,stopWatchingferme les watchers, les deux sont idempotents - Filtrage des chemins : Seuls les événements du nom de base
settings.jsondéclenchent un rafraîchissement ; les fichiers.tmp/.orig/autres sont ignorés - Debouncing : Les événements rapides multiples fusionnent en un seul rechargement (
vi.useFakeTimers()) - Diff sémantique : Contenu inchangé → l’écouteur n’est pas appelé ; contenu modifié → l’écouteur est appelé avec les événements corrects
- Suppression des auto-écritures : Les événements du watcher déclenchés par
setValue()sont naturellement filtrés par un diff identique - Sérialisation : Les nouveaux événements pendant
handleChangesont accumulés, puis vidés une fois le traitement terminé - Isolation des erreurs : Les erreurs de chokidar ne plantent pas ; les exceptions d’un écouteur n’affectent pas les autres ; les échecs de
reloadScopeFromDisksont interceptés - Timeout de l’écouteur : Protection par timeout de 30s
- Surveillance paresseuse des répertoires : Quand
.qwenest absent,mkdirSyncn’est jamais appelé ; un watcher bootstrap est armé sur le parent et son prédicatignoredautorise uniquement l’entrée.qwen - Promotion / TOCTOU : L’apparition de
.qwen(viaaddDirou la revérification post-armement) ferme le watcher bootstrap et ouvre un watcher cible sur.qwen+ planifie un rafraîchissement - Rétrogradation / recréation : La suppression de
.qwen(unlinkDir) réinitialise le bootstrap sur le parent ; une recréation ultérieure déclenche à nouveau une promotion - Garde de génération : Un callback obsolète provenant d’un watcher bootstrap déjà fermé ne crée pas un second watcher cible
Vérification de régression
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/Vérification manuelle
Modifiez ~/.qwen/settings.json pendant une session en cours et observez les logs de débogage pour les événements de modification.
Sous-tâche de suivi : Supprimer les événements pour les paramètres nécessitant un redémarrage et les paramètres sensibles
Statut : la porte de suppression est implémentée ; deux modifications de schéma sont encore en attente de recherche. La sous-tâche 1 ci-dessus émettait un seul
SettingsChangeEventpar scope pour toute modification sémantique. Ce suivi ajoute un filtre afin que les modifications confinées à des paramètres qui ne peuvent pas réellement prendre effet sans redémarrage — ou qui sont sensibles (identifiants) — n’avisent pas les écouteurs.
- Fait : la porte de suppression basée sur
requiresRestartdansSettingsWatcher.handleChange()ainsi que les tests unitaires (voir Mécanisme ci-dessous).- En attente : les deux corrections de schéma
requiresRestart(modelProviders→true,permissions.*→ conserver le rechargement à chaud), chacune conditionnée à la vérification préalable du chemin de lecture à l’exécution.
Motivation
Certains paramètres sont lus exactement une fois au démarrage du processus (Config.initialize(), construction du générateur de contenu/client, lancement de processus enfants, indicateurs du runtime Node). Exemples explicitement mentionnés par l’utilisateur : jetons API, env et fournisseurs de modèles. Émettre un événement de rechargement à chaud pour ceux-ci est activement trompeur — l’écouteur « rafraîchirait » mais la nouvelle valeur ne s’appliquerait pas réellement tant que l’utilisateur ne redémarre pas qwen-code. Les valeurs sensibles (identifiants) ne doivent en outre pas être reconnectées dans une session en cours.
Décision : Réutiliser l’indicateur requiresRestart du schéma (source unique de vérité)
settingsSchema.ts déclare déjà requiresRestart: boolean sur chaque clé, et packages/cli/src/utils/settingsUtils.ts expose déjà les recherches :
requiresRestart(key: string): boolean— indicateur pour une clé en notation pointéegetFlattenedSchema()— carte aplatie complèteclé → définitiongetRestartRequiredSettings()— toutes les clés avecrequiresRestart: true
Nous allons réutiliser cet indicateur comme signal de suppression plutôt que de maintenir une liste d’exclusion distincte écrite à la main (qui dériverait inévitablement du schéma). requiresRestart: true signifie déjà précisément « ne prendra pas effet sans redémarrage », ce qui est exactement la condition sous laquelle un événement doit être supprimé.
Mécanisme (implémenté dans SettingsWatcher.handleChange())
L’ancienne porte effectuait un diff de l’ensemble du fichier avec JSON.stringify et ne pouvait pas dire quelles clés avaient changé. Elle est remplacée par un diff au niveau des feuilles + classification par clé :
collectChangedKeys(before, after)prend un instantané de l’état en mémoire avant le rechargement (structuredClone), puis parcourt before/after et collecte le chemin pointé de chaque feuille dont la valeur diffère. Les objets simples sont récursés ; les tableaux et primitives sont comparés entièrement (correspondant aux clés de tableau du schéma commepermissions.allow). Les clés ajoutées/supprimées apparaissent comme des feuilles modifiées, donc la création/suppression de fichier est couverte sans nécessiter une vérification d’existence séparée.isRestartRequiredKey(path)résout chaque chemin modifié par rapport au schéma en utilisant la clé de schéma la plus longue qui est un préfixe (ou égale) du chemin. Les paramètres d’objets libres (env,modelProviders) sont des clés de schéma feuilles, doncenv.FOOse résout en la définitionenv. Les clés inconnues sont par défaut non nécessitant un redémarrage, donc une modification que nous ne pouvons pas classer n’est jamais silencieusement supprimée.- Le scope notifie uniquement si au moins une clé modifiée est rechargeable à chaud (
!isRestartRequiredKey). Si toutes les clés modifiées nécessitent un redémarrage, le scope ne produit aucun événement.
La forme de SettingsChangeEvent est inchangée (toujours { scope, path, changeType }) ; le transport des clés modifiées survivantes sur l’événement est laissé comme une amélioration ultérieure possible. La suppression des auto-écritures (diff vide → aucun événement), le debounce, la sérialisation et le timeout de l’écouteur restent inchangés.
Deux ajustements de schéma à rechercher et appliquer
Ces deux valeurs requiresRestart doivent être corrigées pour que l’approche de réutilisation se comporte comme prévu. Chacune nécessite de vérifier le chemin de lecture réel à l’exécution avant de basculer l’indicateur.
-
modelProviders:false→true(settingsSchema.ts:294)- Aujourd’hui il est marqué
requiresRestart: false, donc avec l’approche de réutilisation il ne serait pas supprimé — contredisant l’exigence que les modifications de fournisseur ne soient pas rechargées à chaud. - La configuration des fournisseurs (incluant
apiKey/baseUrlpar fournisseur) est consommée lorsque le client de modèle / générateur de contenu est construit au démarrage. - Élément de recherche : confirmer qu’il n’y a pas de relecture à l’exécution de
modelProviders(rechercher dans la construction du générateur de contenu / client). Résultat attendu : lefalseest un bug latent ; basculer àtrue.
- Aujourd’hui il est marqué
-
permissions.*: conserver le rechargement à chaud (settingsSchema.ts:1560, toute la sous-arborescence actuellementrequiresRestart: true)- Les règles d’autorisation (
deny > ask > allow) sont évaluées par appel d’outil et sont censées être les paramètres que les utilisateurs souhaitent le plus voir prendre effet immédiatement. - Toute la sous-arborescence
permissionsestshowInDialog: false, donc son indicateurrequiresRestartn’a actuellement aucune signification dans l’interface — fort indice que letrueétait une valeur par défaut plutôt qu’une décision délibérée « nécessite un redémarrage », donc la zone d’impact du basculement est faible. - Élément de recherche : confirmer que l’exécution relit les permissions en direct (par exemple via
config.getXxx()au moment de l’évaluation) plutôt qu’à partir d’un instantané de démarrage. Si confirmé, définir la sous-arborescencepermissionsàrequiresRestart: falseafin qu’elle ne soit pas supprimée par le mécanisme de réutilisation.
- Les règles d’autorisation (
Remarque : comme
requiresRestartest également affiché dans l’interface des paramètres / les invites de redémarrage, basculer ces indicateurs modifie également ce comportement. C’est acceptable et probablement plus correct, mais doit être mentionné dans la description de la PR.
Critères d’acceptation
- Une modification touchant uniquement des clés nécessitant un redémarrage / sensibles (
security.auth.*,env,modelProviders,mcpServers,proxy, …) n’émet aucunSettingsChangeEvent. - Une modification d’une clé rechargeable à chaud (
ui.*,model.name,permissions.*une fois basculé, …) émet toujours un événement. - Une modification mixte (une clé nécessitant un redémarrage + une clé rechargeable à chaud) émet toujours un événement (la partie rechargeable à chaud a légitimement besoin d’être rafraîchie).
- Une modification d’une clé inconnue (hors schéma) émet toujours, plutôt que d’être silencieusement supprimée.
État des tests :
- Fait — le bloc
restart-required suppressiondesettingsWatcher.test.tscouvre les cas totalement supprimés (env,security.auth.apiKey), totalement autorisés (ui.theme), mixtes et clés inconnues. - En attente (avec les basculements de schéma) — les assertions
settingsSchema.test.tsfixant les deux valeurs corrigées derequiresRestart, et un test du watcher affirmant quepermissions.*n’est plus supprimé une fois basculé.