on main help sign in
feat(tools/crush_info): handle config staleness

Track config file staleness and expose dirty/changed/missing state so crush_info can warn when in-memory config is outdated after on-disk edits. This also makes reload use freshly swapped config state before model/agent setup to avoid stale runtime wiring. ๐Ÿ’˜ Generated with Crush Assisted-by: Kimi-K2.5 via Crush <crush@charm.land>

commit269bc30e2038750b0dbff76f329d5b07775a13e5 authorChristian Rocha <christian@rocha.is> dateSun 5 Apr 2026, 16:42 (6 months ago) parent77fcc76
5 files changed+555โˆ’8
internal/agent/tools/crush_info.go+31 internal/agent/tools/crush_info_test.go+72 internal/config/load.go+4 internal/config/store.go+223โˆ’8 internal/config/store_test.go+225
internal/agent/tools/crush_info.go+31
@@ -36,6 +36,7 @@ func buildCrushInfo(cfg *config.ConfigStore, lspManager *lsp.Manager) string {
3636 var b strings.Builder
3737
3838 writeConfigFiles(&b, cfg)
39+ writeConfigStaleness(&b, cfg)
3940 writeModels(&b, cfg)
4041 writeProviders(&b, cfg)
4142 writeLSP(&b, lspManager, cfg)
@@ -56,6 +57,36 @@ func writeConfigFiles(b *strings.Builder, cfg *config.ConfigStore) {
5657 b.WriteString("\n")
5758}
5859
60+func writeConfigStaleness(b *strings.Builder, cfg *config.ConfigStore) {
61+ staleness := cfg.ConfigStaleness()
62+
63+ b.WriteString("[config]\n")
64+ fmt.Fprintf(b, "dirty = %v\n", staleness.Dirty)
65+
66+ if len(staleness.Changed) > 0 {
67+ sorted := slices.Clone(staleness.Changed)
68+ slices.Sort(sorted)
69+ fmt.Fprintf(b, "changed_paths = %s\n", strings.Join(sorted, ", "))
70+ }
71+
72+ if len(staleness.Missing) > 0 {
73+ sorted := slices.Clone(staleness.Missing)
74+ slices.Sort(sorted)
75+ fmt.Fprintf(b, "missing_paths = %s\n", strings.Join(sorted, ", "))
76+ }
77+
78+ if len(staleness.Errors) > 0 {
79+ var paths []string
80+ for path := range staleness.Errors {
81+ paths = append(paths, path)
82+ }
83+ slices.Sort(paths)
84+ fmt.Fprintf(b, "errors = %s\n", strings.Join(paths, ", "))
85+ }
86+
87+ b.WriteString("\n")
88+}
89+
5990func writeModels(b *strings.Builder, cfg *config.ConfigStore) {
6091 c := cfg.Config()
6192 if len(c.Models) == 0 {
internal/agent/tools/crush_info_test.go+72
@@ -2,6 +2,8 @@ package tools
22
33import (
44 "errors"
5+ "os"
6+ "path/filepath"
57 "strings"
68 "testing"
79 "time"
@@ -298,3 +300,73 @@ func TestCrushInfo_EmptySectionsOmitted(t *testing.T) {
298300 require.NotContains(t, output, "[lsp]")
299301 require.NotContains(t, output, "[mcp]")
300302}
303+
304+func TestCrushInfo_ConfigStaleness_Clean(t *testing.T) {
305+ t.Parallel()
306+
307+ dir := t.TempDir()
308+ configPath := filepath.Join(dir, "crush.json")
309+ require.NoError(t, os.WriteFile(configPath, []byte(`{}`), 0o600))
310+
311+ store := config.NewTestStore(&config.Config{
312+ Providers: csync.NewMap[string, config.ProviderConfig](),
313+ }, configPath)
314+
315+ // Capture snapshot (normally done in Load)
316+ store.CaptureStalenessSnapshot([]string{configPath})
317+
318+ output := buildCrushInfo(store, nil)
319+ require.Contains(t, output, "[config]")
320+ require.Contains(t, output, "dirty = false")
321+ require.NotContains(t, output, "changed_paths")
322+ require.NotContains(t, output, "missing_paths")
323+}
324+
325+func TestCrushInfo_ConfigStaleness_Dirty(t *testing.T) {
326+ t.Parallel()
327+
328+ dir := t.TempDir()
329+ configPath := filepath.Join(dir, "crush.json")
330+ require.NoError(t, os.WriteFile(configPath, []byte(`{"debug": false}`), 0o600))
331+
332+ store := config.NewTestStore(&config.Config{
333+ Providers: csync.NewMap[string, config.ProviderConfig](),
334+ }, configPath)
335+
336+ // Capture initial snapshot
337+ store.CaptureStalenessSnapshot([]string{configPath})
338+
339+ // Modify file to trigger dirty state
340+ time.Sleep(10 * time.Millisecond)
341+ require.NoError(t, os.WriteFile(configPath, []byte(`{"debug": true}`), 0o600))
342+
343+ output := buildCrushInfo(store, nil)
344+ require.Contains(t, output, "[config]")
345+ require.Contains(t, output, "dirty = true")
346+ require.Contains(t, output, "changed_paths")
347+ require.Contains(t, output, configPath)
348+}
349+
350+func TestCrushInfo_ConfigStaleness_MissingPath(t *testing.T) {
351+ t.Parallel()
352+
353+ dir := t.TempDir()
354+ configPath := filepath.Join(dir, "crush.json")
355+ require.NoError(t, os.WriteFile(configPath, []byte(`{}`), 0o600))
356+
357+ store := config.NewTestStore(&config.Config{
358+ Providers: csync.NewMap[string, config.ProviderConfig](),
359+ }, configPath)
360+
361+ // Capture initial snapshot
362+ store.CaptureStalenessSnapshot([]string{configPath})
363+
364+ // Delete file to trigger missing state
365+ require.NoError(t, os.Remove(configPath))
366+
367+ output := buildCrushInfo(store, nil)
368+ require.Contains(t, output, "[config]")
369+ require.Contains(t, output, "dirty = true")
370+ require.Contains(t, output, "missing_paths")
371+ require.Contains(t, output, configPath)
372+}
internal/config/load.go+4
@@ -104,6 +104,10 @@ func Load(workingDir, dataDir string, debug bool) (*ConfigStore, error) {
104104 return nil, fmt.Errorf("failed to configure selected models: %w", err)
105105 }
106106 store.SetupAgents()
107+
108+ // Capture initial staleness snapshot
109+ store.captureStalenessSnapshot(loadedPaths)
110+
107111 return store, nil
108112}
109113
internal/config/store.go+223โˆ’8
@@ -11,6 +11,7 @@ import (
1111
1212 "charm.land/catwalk/pkg/catwalk"
1313 hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
14+ "github.com/charmbracelet/crush/internal/env"
1415 "github.com/charmbracelet/crush/internal/oauth"
1516 "github.com/charmbracelet/crush/internal/oauth/copilot"
1617 "github.com/charmbracelet/crush/internal/oauth/hyper"
@@ -18,6 +19,14 @@ import (
1819 "github.com/tidwall/sjson"
1920)
2021
22+// fileSnapshot captures metadata about a config file at a point in time.
23+type fileSnapshot struct {
24+ Path string
25+ Exists bool
26+ Size int64
27+ ModTime int64 // UnixNano
28+}
29+
2130// RuntimeOverrides holds per-session settings that are never persisted to
2231// disk. They are applied on top of the loaded Config and survive only for
2332// the lifetime of the process (or workspace).
@@ -29,14 +38,16 @@ type RuntimeOverrides struct {
2938// pure-data Config, runtime state (working directory, resolver, known
3039// providers), and persistence to both global and workspace config files.
3140type ConfigStore struct {
32โˆ’ config *Config
33โˆ’ workingDir string
34โˆ’ resolver VariableResolver
35โˆ’ globalDataPath string // ~/.local/share/crush/crush.json
36โˆ’ workspacePath string // .crush/crush.json
37โˆ’ loadedPaths []string // config files that were successfully loaded
38โˆ’ knownProviders []catwalk.Provider
39โˆ’ overrides RuntimeOverrides
41+ config *Config
42+ workingDir string
43+ resolver VariableResolver
44+ globalDataPath string // ~/.local/share/crush/crush.json
45+ workspacePath string // .crush/crush.json
46+ loadedPaths []string // config files that were successfully loaded
47+ knownProviders []catwalk.Provider
48+ overrides RuntimeOverrides
49+ trackedConfigPaths []string // unique, normalized config file paths
50+ snapshots map[string]fileSnapshot // path -> snapshot at last capture
4051}
4152
4253// Config returns the pure-data config struct (read-only after load).
@@ -383,3 +394,207 @@ func (s *ConfigStore) ImportCopilot() (*oauth.Token, bool) {
383394 slog.Info("GitHub Copilot successfully imported")
384395 return token, true
385396}
397+
398+// StalenessResult contains the result of a staleness check.
399+type StalenessResult struct {
400+ Dirty bool
401+ Changed []string
402+ Missing []string
403+ Errors map[string]error // stat errors by path
404+}
405+
406+// ConfigStaleness checks whether any tracked config files have changed on disk
407+// since the last snapshot. Returns dirty=true if any files changed or went
408+// missing, along with sorted lists of affected paths. Stat errors are
409+// captured in Errors map but still treated as non-existence for dirty detection.
410+func (s *ConfigStore) ConfigStaleness() StalenessResult {
411+ var result StalenessResult
412+ result.Errors = make(map[string]error)
413+
414+ for _, path := range s.trackedConfigPaths {
415+ snapshot, hadSnapshot := s.snapshots[path]
416+
417+ info, err := os.Stat(path)
418+ exists := err == nil && !info.IsDir()
419+
420+ if err != nil && !os.IsNotExist(err) {
421+ // Capture permission/IO errors separately from non-existence
422+ result.Errors[path] = err
423+ result.Dirty = true
424+ }
425+
426+ if !exists {
427+ if hadSnapshot && snapshot.Exists {
428+ // File existed before but now missing
429+ result.Missing = append(result.Missing, path)
430+ result.Dirty = true
431+ }
432+ continue
433+ }
434+
435+ // File exists now
436+ if !hadSnapshot || !snapshot.Exists {
437+ // File didn't exist before but does now
438+ result.Changed = append(result.Changed, path)
439+ result.Dirty = true
440+ continue
441+ }
442+
443+ // Check for content or metadata changes
444+ if snapshot.Size != info.Size() || snapshot.ModTime != info.ModTime().UnixNano() {
445+ result.Changed = append(result.Changed, path)
446+ result.Dirty = true
447+ }
448+ }
449+
450+ // Sort for deterministic output
451+ slices.Sort(result.Changed)
452+ slices.Sort(result.Missing)
453+
454+ return result
455+}
456+
457+// RefreshStalenessSnapshot captures fresh snapshots of all tracked config files.
458+// Call this after reloading config to clear dirty state.
459+func (s *ConfigStore) RefreshStalenessSnapshot() error {
460+ if s.snapshots == nil {
461+ s.snapshots = make(map[string]fileSnapshot)
462+ }
463+
464+ for _, path := range s.trackedConfigPaths {
465+ info, err := os.Stat(path)
466+ exists := err == nil && !info.IsDir()
467+
468+ snapshot := fileSnapshot{
469+ Path: path,
470+ Exists: exists,
471+ }
472+
473+ if exists {
474+ snapshot.Size = info.Size()
475+ snapshot.ModTime = info.ModTime().UnixNano()
476+ }
477+
478+ s.snapshots[path] = snapshot
479+ }
480+
481+ return nil
482+}
483+
484+// CaptureStalenessSnapshot captures snapshots for the given paths, building the
485+// tracked config paths list. Paths are deduplicated and normalized.
486+func (s *ConfigStore) CaptureStalenessSnapshot(paths []string) {
487+ // Build unique set of normalized paths
488+ seen := make(map[string]struct{})
489+ for _, p := range paths {
490+ if p == "" {
491+ continue
492+ }
493+ // Normalize path
494+ abs, err := filepath.Abs(p)
495+ if err != nil {
496+ abs = p
497+ }
498+ seen[abs] = struct{}{}
499+ }
500+
501+ // Also track workspace and global config paths if set
502+ if s.workspacePath != "" {
503+ abs, err := filepath.Abs(s.workspacePath)
504+ if err == nil {
505+ seen[abs] = struct{}{}
506+ }
507+ }
508+ if s.globalDataPath != "" {
509+ abs, err := filepath.Abs(s.globalDataPath)
510+ if err == nil {
511+ seen[abs] = struct{}{}
512+ }
513+ }
514+
515+ // Build sorted list for deterministic ordering
516+ s.trackedConfigPaths = make([]string, 0, len(seen))
517+ for p := range seen {
518+ s.trackedConfigPaths = append(s.trackedConfigPaths, p)
519+ }
520+ slices.Sort(s.trackedConfigPaths)
521+
522+ // Capture initial snapshots
523+ s.RefreshStalenessSnapshot()
524+}
525+
526+// captureStalenessSnapshot is an alias for CaptureStalenessSnapshot for internal use.
527+func (s *ConfigStore) captureStalenessSnapshot(paths []string) {
528+ s.CaptureStalenessSnapshot(paths)
529+}
530+
531+// ReloadFromDisk re-runs the config load/merge flow and updates the in-memory
532+// config safely. It rebuilds the staleness snapshot after successful reload.
533+func (s *ConfigStore) ReloadFromDisk(ctx context.Context) error {
534+ if s.workingDir == "" {
535+ return fmt.Errorf("cannot reload: working directory not set")
536+ }
537+
538+ configPaths := lookupConfigs(s.workingDir)
539+ cfg, loadedPaths, err := loadFromConfigPaths(configPaths)
540+ if err != nil {
541+ return fmt.Errorf("failed to reload config: %w", err)
542+ }
543+
544+ // Apply defaults (using existing data directory if set)
545+ var dataDir string
546+ if s.config != nil && s.config.Options != nil {
547+ dataDir = s.config.Options.DataDirectory
548+ }
549+ cfg.setDefaults(s.workingDir, dataDir)
550+
551+ // Merge workspace config if present
552+ workspacePath := filepath.Join(cfg.Options.DataDirectory, fmt.Sprintf("%s.json", appName))
553+ if wsData, err := os.ReadFile(workspacePath); err == nil && len(wsData) > 0 {
554+ merged, mergeErr := loadFromBytes(append([][]byte{mustMarshalConfig(cfg)}, wsData))
555+ if mergeErr == nil {
556+ dataDir := cfg.Options.DataDirectory
557+ *cfg = *merged
558+ cfg.setDefaults(s.workingDir, dataDir)
559+ loadedPaths = append(loadedPaths, workspacePath)
560+ }
561+ }
562+
563+ // Preserve runtime overrides
564+ overrides := s.overrides
565+
566+ // Reconfigure providers
567+ env := env.New()
568+ resolver := NewShellVariableResolver(env)
569+ providers, err := Providers(cfg)
570+ if err != nil {
571+ return fmt.Errorf("failed to load providers during reload: %w", err)
572+ }
573+
574+ if err := cfg.configureProviders(s, env, resolver, providers); err != nil {
575+ return fmt.Errorf("failed to configure providers during reload: %w", err)
576+ }
577+
578+ // Update store state BEFORE running model/agent setup (so they see new config)
579+ s.config = cfg
580+ s.loadedPaths = loadedPaths
581+ s.resolver = resolver
582+ s.knownProviders = providers
583+ s.overrides = overrides
584+ s.workspacePath = workspacePath
585+
586+ // Mirror startup flow: setup models and agents against NEW config
587+ if !cfg.IsConfigured() {
588+ slog.Warn("No providers configured after reload")
589+ } else {
590+ if err := configureSelectedModels(s, providers); err != nil {
591+ return fmt.Errorf("failed to configure selected models during reload: %w", err)
592+ }
593+ s.SetupAgents()
594+ }
595+
596+ // Rebuild staleness tracking
597+ s.captureStalenessSnapshot(loadedPaths)
598+
599+ return nil
600+}
internal/config/store_test.go+225
@@ -1,10 +1,12 @@
11package config
22
33import (
4+ "context"
45 "errors"
56 "os"
67 "path/filepath"
78 "testing"
9+ "time"
810
911 "github.com/stretchr/testify/require"
1012)
@@ -150,3 +152,226 @@ func TestScope_String(t *testing.T) {
150152 require.Equal(t, "workspace", ScopeWorkspace.String())
151153 require.Contains(t, Scope(99).String(), "Scope(99)")
152154}
155+
156+func TestConfigStaleness_CleanImmediatelyAfterSnapshot(t *testing.T) {
157+ t.Parallel()
158+
159+ dir := t.TempDir()
160+ configPath := filepath.Join(dir, "crush.json")
161+
162+ // Create a config file
163+ content := []byte(`{"options": {"debug": true}}`)
164+ require.NoError(t, os.WriteFile(configPath, content, 0o600))
165+
166+ store := &ConfigStore{
167+ config: &Config{},
168+ globalDataPath: configPath,
169+ }
170+ store.captureStalenessSnapshot([]string{configPath})
171+
172+ result := store.ConfigStaleness()
173+ require.False(t, result.Dirty)
174+ require.Empty(t, result.Changed)
175+ require.Empty(t, result.Missing)
176+}
177+
178+func TestConfigStaleness_DetectsFileContentChange(t *testing.T) {
179+ t.Parallel()
180+
181+ dir := t.TempDir()
182+ configPath := filepath.Join(dir, "crush.json")
183+
184+ // Create initial config file
185+ require.NoError(t, os.WriteFile(configPath, []byte(`{"debug": false}`), 0o600))
186+
187+ store := &ConfigStore{
188+ config: &Config{},
189+ globalDataPath: configPath,
190+ }
191+ store.captureStalenessSnapshot([]string{configPath})
192+
193+ // Modify the file
194+ time.Sleep(10 * time.Millisecond) // Ensure different mtime
195+ require.NoError(t, os.WriteFile(configPath, []byte(`{"debug": true}`), 0o600))
196+
197+ result := store.ConfigStaleness()
198+ require.True(t, result.Dirty)
199+ require.Contains(t, result.Changed, configPath)
200+ require.Empty(t, result.Missing)
201+}
202+
203+func TestConfigStaleness_DetectsFileDeletion(t *testing.T) {
204+ t.Parallel()
205+
206+ dir := t.TempDir()
207+ configPath := filepath.Join(dir, "crush.json")
208+
209+ // Create initial config file
210+ require.NoError(t, os.WriteFile(configPath, []byte(`{"debug": true}`), 0o600))
211+
212+ store := &ConfigStore{
213+ config: &Config{},
214+ globalDataPath: configPath,
215+ }
216+ store.captureStalenessSnapshot([]string{configPath})
217+
218+ // Delete the file
219+ require.NoError(t, os.Remove(configPath))
220+
221+ result := store.ConfigStaleness()
222+ require.True(t, result.Dirty)
223+ require.Empty(t, result.Changed)
224+ require.Contains(t, result.Missing, configPath)
225+}
226+
227+func TestConfigStaleness_DetectsNewFile(t *testing.T) {
228+ t.Parallel()
229+
230+ dir := t.TempDir()
231+ configPath := filepath.Join(dir, "crush.json")
232+
233+ // Don't create file initially
234+ store := &ConfigStore{
235+ config: &Config{},
236+ globalDataPath: configPath,
237+ }
238+ store.captureStalenessSnapshot([]string{configPath})
239+
240+ // Now create the file
241+ time.Sleep(10 * time.Millisecond)
242+ require.NoError(t, os.WriteFile(configPath, []byte(`{"debug": true}`), 0o600))
243+
244+ result := store.ConfigStaleness()
245+ require.True(t, result.Dirty)
246+ require.Contains(t, result.Changed, configPath)
247+ require.Empty(t, result.Missing)
248+}
249+
250+func TestConfigStaleness_SortedOutput(t *testing.T) {
251+ t.Parallel()
252+
253+ dir := t.TempDir()
254+ pathA := filepath.Join(dir, "a.json")
255+ pathB := filepath.Join(dir, "b.json")
256+ pathC := filepath.Join(dir, "c.json")
257+
258+ // Create all files
259+ for _, p := range []string{pathA, pathB, pathC} {
260+ require.NoError(t, os.WriteFile(p, []byte(`{}`), 0o600))
261+ }
262+
263+ store := &ConfigStore{
264+ config: &Config{},
265+ globalDataPath: pathA,
266+ }
267+ // Add in reverse order to test sorting
268+ store.captureStalenessSnapshot([]string{pathC, pathA, pathB})
269+
270+ // Modify all files
271+ time.Sleep(10 * time.Millisecond)
272+ for _, p := range []string{pathA, pathB, pathC} {
273+ require.NoError(t, os.WriteFile(p, []byte(`{"changed": true}`), 0o600))
274+ }
275+
276+ result := store.ConfigStaleness()
277+ require.True(t, result.Dirty)
278+ // Should be sorted alphabetically
279+ require.Equal(t, []string{pathA, pathB, pathC}, result.Changed)
280+}
281+
282+func TestConfigStaleness_RefreshClearsDirtyState(t *testing.T) {
283+ t.Parallel()
284+
285+ dir := t.TempDir()
286+ configPath := filepath.Join(dir, "crush.json")
287+
288+ // Create initial config file
289+ require.NoError(t, os.WriteFile(configPath, []byte(`{"debug": false}`), 0o600))
290+
291+ store := &ConfigStore{
292+ config: &Config{},
293+ globalDataPath: configPath,
294+ }
295+ store.captureStalenessSnapshot([]string{configPath})
296+
297+ // Modify the file
298+ time.Sleep(10 * time.Millisecond)
299+ require.NoError(t, os.WriteFile(configPath, []byte(`{"debug": true}`), 0o600))
300+
301+ // Verify dirty
302+ result := store.ConfigStaleness()
303+ require.True(t, result.Dirty)
304+
305+ // Refresh snapshot
306+ require.NoError(t, store.RefreshStalenessSnapshot())
307+
308+ // Verify clean now
309+ result = store.ConfigStaleness()
310+ require.False(t, result.Dirty)
311+ require.Empty(t, result.Changed)
312+ require.Empty(t, result.Missing)
313+}
314+
315+// TestReloadFromDisk_UsesNewConfigValues is a regression test ensuring that
316+// ReloadFromDisk updates store state BEFORE running model/agent setup,
317+// so the new config values are used rather than stale pre-reload values.
318+func TestReloadFromDisk_UsesNewConfigValues(t *testing.T) {
319+ t.Parallel()
320+
321+ dir := t.TempDir()
322+ configPath := filepath.Join(dir, "crush.json")
323+
324+ // Create initial config with one model preference
325+ initialConfig := `{
326+ "models": {
327+ "large": {"provider": "openai", "model": "gpt-4"}
328+ },
329+ "providers": {
330+ "openai": {
331+ "api_key": "test-key",
332+ "models": [{"id": "gpt-4", "name": "GPT-4"}]
333+ }
334+ }
335+ }`
336+ require.NoError(t, os.WriteFile(configPath, []byte(initialConfig), 0o600))
337+
338+ // Load initial config properly
339+ store, err := Load(dir, dir, false)
340+ require.NoError(t, err)
341+
342+ // Set globalDataPath for the test (Load doesn't set this directly)
343+ store.globalDataPath = configPath
344+ store.CaptureStalenessSnapshot([]string{configPath})
345+
346+ // Verify initial model
347+ require.Equal(t, "openai", store.config.Models[SelectedModelTypeLarge].Provider)
348+ require.Equal(t, "gpt-4", store.config.Models[SelectedModelTypeLarge].Model)
349+
350+ // Modify config on disk to change model
351+ updatedConfig := `{
352+ "models": {
353+ "large": {"provider": "anthropic", "model": "claude-3"}
354+ },
355+ "providers": {
356+ "openai": {
357+ "api_key": "test-key",
358+ "models": [{"id": "gpt-4", "name": "GPT-4"}]
359+ },
360+ "anthropic": {
361+ "api_key": "test-key-2",
362+ "models": [{"id": "claude-3", "name": "Claude 3"}]
363+ }
364+ }
365+ }`
366+ time.Sleep(10 * time.Millisecond)
367+ require.NoError(t, os.WriteFile(configPath, []byte(updatedConfig), 0o600))
368+
369+ // Reload from disk
370+ ctx := context.Background()
371+ err = store.ReloadFromDisk(ctx)
372+ require.NoError(t, err)
373+
374+ // Verify the NEW config values are now in effect (regression check)
375+ require.Equal(t, "anthropic", store.config.Models[SelectedModelTypeLarge].Provider)
376+ require.Equal(t, "claude-3", store.config.Models[SelectedModelTypeLarge].Model)
377+}