@@ -48,6 +48,8 @@ type ConfigStore struct {
48 overrides RuntimeOverrides
48 overrides RuntimeOverrides
49 trackedConfigPaths []string // unique, normalized config file paths
49 trackedConfigPaths []string // unique, normalized config file paths
50 snapshots map[string]fileSnapshot // path -> snapshot at last capture
50 snapshots map[string]fileSnapshot // path -> snapshot at last capture
51+ autoReloadDisabled bool // set during load/reload to prevent re-entrancy
52+ reloadInProgress bool // set during reload to avoid disk writes mid-reload
53// Config returns the pure-data config struct (read-only after load).
55// Config returns the pure-data config struct (read-only after load).
@@ -121,6 +123,8 @@ func (s *ConfigStore) HasConfigField(scope Scope, key string) bool {
123// SetConfigField sets a key/value pair in the config file for the given scope.
125// SetConfigField sets a key/value pair in the config file for the given scope.
126+// After a successful write, it automatically reloads config to keep in-memory
124func (s *ConfigStore) SetConfigField(scope Scope, key string, value any) error {
128func (s *ConfigStore) SetConfigField(scope Scope, key string, value any) error {
125 path, err := s.configPath(scope)
129 path, err := s.configPath(scope)
126 if err != nil {
130 if err != nil {
@@ -145,10 +149,21 @@ func (s *ConfigStore) SetConfigField(scope Scope, key string, value any) error {
145 if err := os.WriteFile(path, []byte(newValue), 0o600); err != nil {
149 if err := os.WriteFile(path, []byte(newValue), 0o600); err != nil {
146 return fmt.Errorf("failed to write config file: %w", err)
150 return fmt.Errorf("failed to write config file: %w", err)
153+ // Auto-reload to keep in-memory state fresh after config edits.
154+ // We use context.Background() since this is an internal operation that
155+ // shouldn't be cancelled by user context.
156+ if err := s.autoReload(context.Background()); err != nil {
157+ // Log warning but don't fail the write - disk is already updated.
158+ slog.Warn("Config file updated but failed to reload in-memory state", "error", err)
148 return nil
161 return nil
151// RemoveConfigField removes a key from the config file for the given scope.
164// RemoveConfigField removes a key from the config file for the given scope.
165+// After a successful write, it automatically reloads config to keep in-memory
152func (s *ConfigStore) RemoveConfigField(scope Scope, key string) error {
167func (s *ConfigStore) RemoveConfigField(scope Scope, key string) error {
153 path, err := s.configPath(scope)
168 path, err := s.configPath(scope)
154 if err != nil {
169 if err != nil {
@@ -169,6 +184,12 @@ func (s *ConfigStore) RemoveConfigField(scope Scope, key string) error {
169 if err := os.WriteFile(path, []byte(newValue), 0o600); err != nil {
184 if err := os.WriteFile(path, []byte(newValue), 0o600); err != nil {
170 return fmt.Errorf("failed to write config file: %w", err)
185 return fmt.Errorf("failed to write config file: %w", err)
188+ // Auto-reload to keep in-memory state fresh after config edits.
189+ if err := s.autoReload(context.Background()); err != nil {
190+ slog.Warn("Config file updated but failed to reload in-memory state", "error", err)
172 return nil
193 return nil
@@ -529,12 +550,21 @@ func (s *ConfigStore) captureStalenessSnapshot(paths []string) {
531// ReloadFromDisk re-runs the config load/merge flow and updates the in-memory
552// ReloadFromDisk re-runs the config load/merge flow and updates the in-memory
532−// config safely. It rebuilds the staleness snapshot after successful reload.
553+// config atomically. It rebuilds the staleness snapshot after successful reload.
554+// On failure, the store state is rolled back to its previous state.
533func (s *ConfigStore) ReloadFromDisk(ctx context.Context) error {
555func (s *ConfigStore) ReloadFromDisk(ctx context.Context) error {
534 if s.workingDir == "" {
556 if s.workingDir == "" {
535 return fmt.Errorf("cannot reload: working directory not set")
557 return fmt.Errorf("cannot reload: working directory not set")
560+ // Disable auto-reload during reload to prevent nested/re-entrant calls.
561+ s.autoReloadDisabled = true
562+ s.reloadInProgress = true
564+ s.autoReloadDisabled = false
565+ s.reloadInProgress = false
538 configPaths := lookupConfigs(s.workingDir)
568 configPaths := lookupConfigs(s.workingDir)
539 cfg, loadedPaths, err := loadFromConfigPaths(configPaths)
569 cfg, loadedPaths, err := loadFromConfigPaths(configPaths)
540 if err != nil {
570 if err != nil {
@@ -575,6 +605,14 @@ func (s *ConfigStore) ReloadFromDisk(ctx context.Context) error {
575 return fmt.Errorf("failed to configure providers during reload: %w", err)
605 return fmt.Errorf("failed to configure providers during reload: %w", err)
608+ // Save current state for potential rollback
609+ oldConfig := s.config
610+ oldLoadedPaths := s.loadedPaths
611+ oldResolver := s.resolver
612+ oldKnownProviders := s.knownProviders
613+ oldOverrides := s.overrides
614+ oldWorkspacePath := s.workspacePath
578 // Update store state BEFORE running model/agent setup (so they see new config)
616 // Update store state BEFORE running model/agent setup (so they see new config)
579 s.config = cfg
617 s.config = cfg
580 s.loadedPaths = loadedPaths
618 s.loadedPaths = loadedPaths
@@ -584,13 +622,26 @@ func (s *ConfigStore) ReloadFromDisk(ctx context.Context) error {
584 s.workspacePath = workspacePath
622 s.workspacePath = workspacePath
586 // Mirror startup flow: setup models and agents against NEW config
624 // Mirror startup flow: setup models and agents against NEW config
587 if !cfg.IsConfigured() {
626 if !cfg.IsConfigured() {
588 slog.Warn("No providers configured after reload")
627 slog.Warn("No providers configured after reload")
590− if err := configureSelectedModels(s, providers); err != nil {
629+ if err := configureSelectedModels(s, providers, false); err != nil {
591− return fmt.Errorf("failed to configure selected models during reload: %w", err)
630+ setupErr = fmt.Errorf("failed to configure selected models during reload: %w", err)
593− s.SetupAgents()
634+ }
636+ // Rollback on setup failure
637+ if setupErr != nil {
638+ s.config = oldConfig
639+ s.loadedPaths = oldLoadedPaths
640+ s.resolver = oldResolver
641+ s.knownProviders = oldKnownProviders
642+ s.overrides = oldOverrides
643+ s.workspacePath = oldWorkspacePath
596 // Rebuild staleness tracking
647 // Rebuild staleness tracking
@@ -598,3 +649,17 @@ func (s *ConfigStore) ReloadFromDisk(ctx context.Context) error {
599 return nil
650 return nil
653+// autoReload conditionally reloads config from disk after writes.
654+// It returns nil (no error) for expected skip cases: when auto-reload is
655+// disabled during load/reload flows, or when working directory is not set
656+// (e.g., during testing). Only actual reload failures return an error.
657+func (s *ConfigStore) autoReload(ctx context.Context) error {
658+ if s.autoReloadDisabled {
659+ return nil // Expected skip: already in load/reload flow
661+ if s.workingDir == "" {
662+ return nil // Expected skip: working directory not set
664+ return s.ReloadFromDisk(ctx)