fix(daemon): deduplicate file and git ref change log messages

The daemon.log was showing duplicate log messages:
- 'File change detected' appeared 4x for a single file change
- 'Git ref change detected' appeared 2x for a single ref update

Root cause:
- fsnotify generates multiple events (Write, Create, Chmod) for single file ops
- Both parent directory and file watchers can trigger for the same change
- Multiple git ref files may be updated simultaneously

Fix:
- Add log deduplication with 500ms window (matching debouncer window)
- Track last log time for file changes and git ref changes separately
- Only log if enough time has passed since last log of same type
- Still trigger debouncer for every event (functionality unchanged)

This reduces log noise while maintaining full functionality.
This commit is contained in:
Charles P. Cross
2025-12-22 19:02:42 -05:00
parent 4c38075520
commit 195091711e

View File

@@ -30,6 +30,11 @@ type FileWatcher struct {
lastHeadExists bool
cancel context.CancelFunc
wg sync.WaitGroup // Track goroutines for graceful shutdown (bd-jo38)
// Log deduplication: track last log times to avoid duplicate messages
lastFileLogTime time.Time
lastGitRefLogTime time.Time
logDedupeWindow time.Duration
logMu sync.Mutex
}
// NewFileWatcher creates a file watcher for the given JSONL path.
@@ -37,10 +42,11 @@ type FileWatcher struct {
// Falls back to polling mode if fsnotify fails (controlled by BEADS_WATCHER_FALLBACK env var).
func NewFileWatcher(jsonlPath string, onChanged func()) (*FileWatcher, error) {
fw := &FileWatcher{
jsonlPath: jsonlPath,
parentDir: filepath.Dir(jsonlPath),
debouncer: NewDebouncer(500*time.Millisecond, onChanged),
pollInterval: 5 * time.Second,
jsonlPath: jsonlPath,
parentDir: filepath.Dir(jsonlPath),
debouncer: NewDebouncer(500*time.Millisecond, onChanged),
pollInterval: 5 * time.Second,
logDedupeWindow: 500 * time.Millisecond, // Deduplicate logs within this window
}
// Get initial file state for polling fallback
@@ -120,6 +126,30 @@ func NewFileWatcher(jsonlPath string, onChanged func()) (*FileWatcher, error) {
return fw, nil
}
// shouldLogFileChange returns true if enough time has passed since last file change log
func (fw *FileWatcher) shouldLogFileChange() bool {
fw.logMu.Lock()
defer fw.logMu.Unlock()
now := time.Now()
if now.Sub(fw.lastFileLogTime) >= fw.logDedupeWindow {
fw.lastFileLogTime = now
return true
}
return false
}
// shouldLogGitRefChange returns true if enough time has passed since last git ref change log
func (fw *FileWatcher) shouldLogGitRefChange() bool {
fw.logMu.Lock()
defer fw.logMu.Unlock()
now := time.Now()
if now.Sub(fw.lastGitRefLogTime) >= fw.logDedupeWindow {
fw.lastGitRefLogTime = now
return true
}
return false
}
// Start begins monitoring filesystem events or polling.
// Runs in background goroutine until context is canceled.
// Should only be called once per FileWatcher instance.
@@ -156,7 +186,9 @@ func (fw *FileWatcher) Start(ctx context.Context, log daemonLogger) {
// Handle JSONL write/chmod events
if event.Name == fw.jsonlPath && event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Chmod) != 0 {
log.log("File change detected: %s (op: %v)", event.Name, event.Op)
if fw.shouldLogFileChange() {
log.log("File change detected: %s", event.Name)
}
fw.debouncer.Trigger()
continue
}
@@ -179,7 +211,9 @@ func (fw *FileWatcher) Start(ctx context.Context, log daemonLogger) {
// Handle git ref changes (only events under gitRefsPath)
if event.Op&fsnotify.Write != 0 && strings.HasPrefix(event.Name, fw.gitRefsPath) {
log.log("Git ref change detected: %s", event.Name)
if fw.shouldLogGitRefChange() {
log.log("Git ref change detected: %s", event.Name)
}
fw.debouncer.Trigger()
continue
}