How Agentsview Implements Debounce and Recursive File Watching in Go
Agentsview wraps the fsnotify library with a custom Watcher struct that recursively walks directory trees and coalesces rapid filesystem events using a time-based pending map flushed by a ticker, ensuring the onChange callback fires only after a configurable quiet period.
The kenn-io/agentsview repository provides a robust file synchronization system for AI agent sessions. At its core, the internal/sync/watcher.go file implements a sophisticated filesystem monitor that handles the complexity of watching deep directory hierarchies while preventing notification storms through intelligent debouncing.
Core Architecture and Data Structures
The implementation centers on a Watcher struct defined in internal/sync/watcher.go (lines 31-41) that orchestrates the relationship between the low-level fsnotify instance and application-level concerns.
type Watcher struct {
onChange func(paths []string) // callback invoked after debounce
watcher *fsnotify.Watcher // low-level fsnotify instance
debounce time.Duration // debounce interval
excludes []string // excluded paths (normalized)
roots []string // all watched roots
shallow []string // roots watched without recursion
rootsMu sync.RWMutex
pending map[string]time.Time // paths seen but not yet flushed
mu sync.Mutex
stop chan struct{}
done chan struct{}
stopOnce sync.Once
now func() time.Time // injected for testing
}
The pending map serves as the critical data structure for the debounce mechanism, storing paths alongside their last-event timestamps. The shallow slice tracks roots that should not be recursively walked, while rootsMu protects concurrent access to the watched directory list.
Recursive Directory Watching
Agentsview implements recursive watching through explicit directory tree traversal rather than relying on native recursive watch APIs. The WatchRecursive method calls WatchRecursiveBudgeted with math.MaxInt as the budget, delegating to the budget-aware implementation at internal/sync/watcher.go (lines 80-119).
The budgeted variant uses filepath.WalkDir to traverse the tree, attempting to add each directory to the underlying fsnotify watcher via w.watcher.Add(path). The walk respects two stopping conditions:
- Budget exhaustion: When the count of watched directories hits the configured maximum, flagged via
result.BudgetExhausted. - Resource exhaustion: When the OS returns
EMFILEorENOSPCerrors, captured inresult.ResourceExhausted.
During traversal, the walker checks shouldExcludeForRoot to skip sub-trees matching exclusion patterns, and updates result.Watched and result.Unwatched counters for observability.
Shallow Root Handling
For directories containing thousands of sub-directories (such as a top-level sessions folder), the WatchShallow method (defined near line 30-39 in the same file) adds only the root path to the watch list. This prevents hitting OS file descriptor limits while still catching direct changes to the root directory itself.
Automatic Addition of New Sub-Directories
When the watcher detects a Create event, the handleEvent function (lines 85-99) determines whether the new path is a directory. If so, watchIfDir adds it to the active watch list unless it falls under a shallow root or matches an exclusion pattern.
if event.Op&fsnotify.Create != 0 {
isDir, excluded := w.watchIfDir(event.Name)
if isDir && excluded { return }
}
This dynamic expansion ensures that newly created session directories immediately enter the monitoring system without requiring a full rescan.
Debounce Implementation Mechanism
The debounce system operates as a three-stage pipeline that transforms volatile filesystem activity into stable, coalesced notifications.
Event Gathering: When handleEvent receives relevant operations (Write, Create, Remove, or Rename), it records the path and current timestamp in the pending map:
w.mu.Lock()
w.pending[event.Name] = w.now()
w.mu.Unlock()
Ticker Loop: The Watcher.Start method spawns a goroutine running the loop function, which creates a time.NewTicker using the configured debounce duration (lines 55-62). Each tick triggers flush():
ticker := time.NewTicker(w.debounce)
// ...
case <-ticker.C:
w.flush()
Flush Logic: The flush method (lines 45-70) iterates over pending, selecting entries where now.Sub(t) >= w.debounce. These ready paths are removed from the map and passed to the user-provided onChange callback as a slice.
This design guarantees that a burst of rapid modifications results in exactly one callback invocation after the filesystem has been quiet for the specified duration.
Exclusion Pattern Handling
Exclusion patterns are normalized once during initialization via normalizeExcludePatterns and applied per-root during recursive walks. The matching logic handles both whole-path patterns (containing path separators) and simple filename patterns, allowing developers to exclude version control directories (".git") or dependency folders ("node_modules") efficiently.
Practical Usage Example
The following pattern mirrors the implementation in cmd/agentsview/session_watch.go, demonstrating how to wire the watcher into a server application:
// Create a watcher that fires after 500ms of inactivity.
debounce := 500 * time.Millisecond
watcher, err := sync.NewWatcher(debounce, func(paths []string) {
// Trigger a sync of the changed session files.
syncEngine.Sync(paths)
}, []string{".git", "node_modules"})
if err != nil {
log.Fatalf("cannot create watcher: %v", err)
}
// Watch the entire sessions directory recursively (budget = 10,000 dirs).
watched, unwatched, err := watcher.WatchRecursive("/home/user/.agentsview/sessions")
log.Printf("watched %d dirs, skipped %d", watched, unwatched)
// Start processing events.
watcher.Start()
// ... later, when shutting down ...
watcher.Stop()
Summary
- Recursive watching in Agentsview uses explicit
filepath.WalkDirtraversal with configurable budget limits to avoid OS resource exhaustion, implemented primarily ininternal/sync/watcher.go. - Debounce mechanics rely on a
pendingmap that timestamps each path, flushed by atime.Tickeronly after the configured interval has elapsed without new events. - Dynamic expansion automatically adds newly created directories to the watch list through
watchIfDir, while shallow watching provides a fallback for deep hierarchies. - Exclusion patterns are normalized and checked during traversal to skip irrelevant sub-trees like
.gitornode_modules.
Frequently Asked Questions
How does Agentsview handle the OS limit on watched directories?
Agentsview implements a budget system through WatchRecursiveBudgeted that stops traversing subdirectories once a configurable maximum is reached, setting result.BudgetExhausted to true. It also catches EMFILE and ENOSPC errors from the underlying fsnotify watcher, storing these in result.ResourceExhausted to signal resource constraints.
What is the difference between WatchRecursive and WatchShallow?
WatchRecursive walks the entire directory tree and attempts to add every sub-directory to the fsnotify watch list, subject to budget constraints. WatchShallow adds only the root directory itself, avoiding the overhead and resource consumption of monitoring deep hierarchies while still detecting changes to files directly in that root.
How does the debounce ticker prevent notification storms?
The implementation uses a time.Ticker running at the configured debounce interval. Every filesystem event updates a timestamp in the pending map, but the onChange callback only fires when flush finds paths that have remained in the map longer than the debounce duration. This coalesces rapid successive events into a single callback after the filesystem becomes quiet.
Can the debounce interval be adjusted for different latency requirements?
Yes. The NewWatcher constructor accepts a time.Duration parameter that sets the debounce interval. The code uses this value to initialize both the pending map's time-to-live logic and the ticker period, allowing developers to tune the trade-off between notification latency and event coalescing efficiency.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →