# How Agentsview Implements Debounce and Recursive File Watching in Go

> See how Agentsview uses fsnotify with a custom Watcher to recursively watch files and debounce events. Learn how it coalesces rapid filesystem events for efficient callback execution.

- Repository: [Kenn Software/agentsview](https://github.com/kenn-io/agentsview)
- Tags: internals
- Published: 2026-06-20

---

**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`](https://github.com/kenn-io/agentsview/blob/main/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`](https://github.com/kenn-io/agentsview/blob/main/internal/sync/watcher.go) (lines 31-41) that orchestrates the relationship between the low-level fsnotify instance and application-level concerns.

```go
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`](https://github.com/kenn-io/agentsview/blob/main/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 `EMFILE` or `ENOSPC` errors, captured in `result.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.

```go
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:

```go
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()`:

```go
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`](https://github.com/kenn-io/agentsview/blob/main/cmd/agentsview/session_watch.go), demonstrating how to wire the watcher into a server application:

```go
// 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.WalkDir` traversal with configurable budget limits to avoid OS resource exhaustion, implemented primarily in [`internal/sync/watcher.go`](https://github.com/kenn-io/agentsview/blob/main/internal/sync/watcher.go).
- **Debounce mechanics** rely on a `pending` map that timestamps each path, flushed by a `time.Ticker` only 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 `.git` or `node_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.