Files
coder/agent/agentcontext/watcher.go
T
Kyle Carberry 0f37522e6f refactor(agent/agentcontext): fixed-location shallow context discovery (#26596)
## Problem

`agent/agentcontext` resolved workspace context by walking the working
directory recursively (depth 8) and matching files by basename. This
over-injected context:

- Instruction-file matching was case-insensitive, so the generated
`docs/reference/api/agents.md` was treated as an instruction file.
- Symlinked instruction files (`CLAUDE.md`, `.cursorrules` ->
`AGENTS.md`) shipped as duplicate resources.
- Nested `AGENTS.md` (e.g. `site/AGENTS.md`) were collected from
anywhere in the tree.
- Skills were discovered from *any* `skills/` directory anywhere in the
tree, and `.mcp.json` from any depth.

Resolving the repo root produced six instruction sources for what was
effectively one file of guidance, plus skills/MCP found by an open-ended
walk.

## What changed

Replace the recursive scan with **fixed-location, shallow** discovery.
Each scan root is inspected at its top level only: the resolver never
descends into subdirectories and never climbs to a parent. Additional
directories are added explicitly as sources (HTTP API) or via the
`CODER_AGENT_EXP_*_DIRS` seeding env vars.

- **Single working-dir scan root.** The working directory is one scan
root. Instruction files and `.mcp.json` are read only at its top level.
- **Fixed-location skills.** Skills are discovered only from `skills`,
`.agents/skills`, `.claude/skills`, `.codex/skills` (one skill per
immediate subdir with a `SKILL.md`), not from arbitrary `skills/`
directories.
- **Case-sensitive instruction names.** Exact
`AGENTS.md`/`CLAUDE.md`/`.cursorrules`; a lower-case `agents.md` is
ignored.
- **Symlink dedup.** Resources are attributed to their resolved target,
so symlinked `CLAUDE.md`/`.cursorrules` collapse into the single
`AGENTS.md`.
- **Watcher** mirrors the same fixed-location set instead of recursively
watching every scan root (no more walking `node_modules`).
- The recursive `walkDir`, `skipDirNames`, `MaxScanDepth`, and
`isSkillsContainer` are removed.

Resolving the repo root now yields `AGENTS.md`, `.mcp.json`, and the
`.agents/skills`/`.claude/skills` skills, with no nested
instruction-file noise.

## Behavior change

`site/AGENTS.md` is no longer auto-injected when the working dir is the
repo root. It loads when the working dir **is** `site/` (its top level),
or when `site/` is added as an explicit source. There is intentionally
**no walk-up** to a `.git` project root: an agent started in a
subdirectory does not auto-inherit ancestor `AGENTS.md`; those
directories are added explicitly.

<details>
<summary>Verification &amp; decision log</summary>

**codex research (confirmed via source).** Instruction files: codex
walks up to the first `.git` ancestor and reads root-&gt;cwd, one file
per directory, exact-cased names (`codex-rs/core/src/agents_md.rs`).
Skills: fixed roots (`.agents/skills`, `.codex/skills`,
`$CODEX_HOME/skills`, ...) with bounded in-root recursion
(`core-skills/src/loader.rs`). MCP: `.codex/config.toml` via walk-up; a
project `.mcp.json` is not a runtime source in codex. No resource type
triggers an unbounded downward walk.

**Decisions.**
- Adopt codex's fixed-location, shallow discovery (case-sensitive names,
symlink dedup, top-level-only files, container-only skills).
- **Deliberately omit codex's walk-up to the `.git` project root.** In
Coder the working dir is the scan root and extra directories are added
explicitly (HTTP API / `CODER_AGENT_EXP_*_DIRS`), so the implicit
ancestor climb added surprise without benefit (e.g. `context add ./site`
should scan `./site`, not the repo root).
- Keep `.mcp.json` (codex uses `config.toml`, intentionally not added).
- Include `.claude/skills` and `.codex/skills` in the container list so
the repo's existing `.claude/skills` skills are not regressed; skills
recurse one level inside a container.

**Tests.** `TestManager_WorkingDirScannedShallow` (working dir read at
top level; ancestor root and nested subdir both excluded);
`TestResolver_SkillsOnlyFromFixedContainers`,
`TestResolver_MCPConfigOnlyAtScanRoot`,
`TestResolver_SymlinkedInstructionFilesDeduplicated`,
`TestResolver_InstructionFilesOnlyAtScanRoot`,
`TestResolver_InstructionNamesAreCaseSensitive`. Cap tests use multiple
scan roots.

**Local checks.** `gofmt`, `go vet`, `golangci-lint`, `go test -race`,
and `make lint/emdash` pass for the package.
</details>

---
🤖 Generated by Coder Agents on behalf of @kylecarbs.
2026-06-23 03:42:40 +00:00

376 lines
9.9 KiB
Go

package agentcontext
import (
"context"
"errors"
"os"
"path/filepath"
"sync"
"syscall"
"time"
"github.com/fsnotify/fsnotify"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/quartz"
)
// DefaultWatchDebounce coalesces editor-style multi-event writes
// (truncate plus rename plus chmod) into a single re-resolve.
// Mirrors the debounce window the existing MCP config watcher
// uses so behavior is consistent across the agent.
const DefaultWatchDebounce = 250 * time.Millisecond
// WatcherOptions parameterizes the watcher.
type WatcherOptions struct {
Logger slog.Logger
Clock quartz.Clock
Debounce time.Duration
// OnChange runs at most once per debounce window. The
// caller must not block; the recommended pattern is a
// non-blocking send on a re-resolve trigger channel.
OnChange func()
}
// Watcher is a fixed-location fsnotify wrapper. It watches only
// the directories that can hold recognized resources (each scan
// root plus its skill containers and immediate skill dirs) rather
// than walking the tree, mirroring the resolver's fixed-location
// discovery. Inotify ENOSPC degrades the watcher into a poll-only
// mode that still re-resolves on Sync calls.
type Watcher struct {
logger slog.Logger
clock quartz.Clock
debounce time.Duration
onChange func()
mu sync.Mutex
watcher *fsnotify.Watcher
watched map[string]struct{}
timer *quartz.Timer
degraded string // non-empty when the watcher dropped events
closed bool
closedCh chan struct{}
runDoneCh chan struct{}
}
// NewWatcher constructs a recursive watcher. The watcher does
// nothing until Sync is called.
func NewWatcher(opts WatcherOptions) (*Watcher, error) {
if opts.OnChange == nil {
return nil, xerrors.New("OnChange callback is required")
}
debounce := opts.Debounce
if debounce <= 0 {
debounce = DefaultWatchDebounce
}
clock := opts.Clock
if clock == nil {
clock = quartz.NewReal()
}
w, err := fsnotify.NewWatcher()
if err != nil {
// On Linux, fsnotify.NewWatcher only fails when the
// inotify subsystem is at the system-wide watch
// limit. Surface a Watcher in "degraded" mode so the
// caller can still rely on explicit Sync triggers.
degraded := &Watcher{
logger: opts.Logger,
clock: clock,
debounce: debounce,
onChange: opts.OnChange,
watched: make(map[string]struct{}),
degraded: "fsnotify init failed: " + err.Error(),
closedCh: make(chan struct{}),
runDoneCh: closedChan(),
}
return degraded, nil
}
cw := &Watcher{
logger: opts.Logger,
clock: clock,
debounce: debounce,
onChange: opts.OnChange,
watcher: w,
watched: make(map[string]struct{}),
closedCh: make(chan struct{}),
runDoneCh: make(chan struct{}),
}
go cw.run()
return cw, nil
}
// closedChan returns an already-closed channel for the
// degraded-watcher case where there is no run goroutine.
func closedChan() chan struct{} {
c := make(chan struct{})
close(c)
return c
}
// Degraded returns a non-empty string when the watcher is
// running with reduced functionality (typically inotify
// ENOSPC). The string is suitable for use as a snapshot-level
// error message.
func (w *Watcher) Degraded() string {
w.mu.Lock()
defer w.mu.Unlock()
return w.degraded
}
// Sync replaces the set of watched directories with the fixed
// locations that can hold recognized resources: each scan root,
// its skill containers, and the immediate skill subdirectories.
// Files are not watched directly; watching the parent directory
// catches creates, renames, removes, and writes that touch any
// recognized basename. Files that are themselves scan roots are
// handled by watching their parent.
//
// Sync is idempotent and safe to call repeatedly. The lock is
// released around the directory scan so concurrent Close,
// schedule, and the run goroutine are not blocked by a slow
// filesystem.
func (w *Watcher) Sync(ctx context.Context, roots []ScanRoot) {
w.mu.Lock()
if w.closed {
w.mu.Unlock()
return
}
if w.watcher == nil {
// Degraded mode: no fsnotify, so there is nothing
// to wire up. Do NOT fire the OnChange callback
// from here; the Manager's signal handler is the
// usual OnChange, and the Run loop calls back into
// Sync when it observes that signal. Firing here
// would re-arm an endless 250ms scan-and-push loop
// on hosts where inotify cannot initialize. Manual
// Resync, AddSource, and RemoveSource still drive
// re-resolves; auto-updates on file edits simply
// do not happen until fsnotify recovers.
w.mu.Unlock()
return
}
w.mu.Unlock()
// collectDirs touches the filesystem (stat/ReadDir on every
// scan root and skill container). Compute the desired set
// outside the mutex so it does not block the run goroutine,
// Close, or schedule.
desired := w.collectDirs(roots)
w.mu.Lock()
defer w.mu.Unlock()
if w.closed {
return
}
// Remove directories no longer wanted.
for path := range w.watched {
if _, ok := desired[path]; ok {
continue
}
_ = w.watcher.Remove(path)
delete(w.watched, path)
}
// Track whether every Add in this pass succeeded so a
// recovered ENOSPC clears the degraded marker.
addedAll := true
// Add directories that are new.
for path := range desired {
if _, ok := w.watched[path]; ok {
continue
}
if err := w.watcher.Add(path); err != nil {
// ENOSPC means the kernel's per-user inotify
// watch budget is exhausted. Mark the watcher
// degraded; subsequent Sync calls still fire
// the change callback so resync still works.
if errors.Is(err, syscall.ENOSPC) {
w.degraded = "inotify watch limit exceeded (ENOSPC)"
addedAll = false
w.logger.Warn(ctx, "context watcher degraded: inotify watch limit exceeded",
slog.F("dir", path))
break
}
w.logger.Debug(ctx, "context watcher could not add dir",
slog.F("dir", path), slog.Error(err))
continue
}
w.watched[path] = struct{}{}
}
// Clear a previously-set ENOSPC mark when every Add in this
// pass succeeded. A user who bumps the kernel's inotify
// limit and re-syncs now sees a clean snapshot instead of a
// permanent SnapshotError.
if addedAll && w.degraded != "" {
w.degraded = ""
}
}
// Close stops the watcher and releases all kernel watch slots.
// Close is idempotent.
func (w *Watcher) Close() error {
w.mu.Lock()
if w.closed {
w.mu.Unlock()
return nil
}
w.closed = true
close(w.closedCh)
timer := w.timer
watcher := w.watcher
w.timer = nil
w.watcher = nil
w.mu.Unlock()
if timer != nil {
timer.Stop()
}
if watcher != nil {
_ = watcher.Close()
}
<-w.runDoneCh
return nil
}
// run forwards fsnotify events into the debounce timer. It exits
// when Close is called or the underlying watcher is closed.
func (w *Watcher) run() {
defer close(w.runDoneCh)
// Capture the watcher reference once. Close may set the
// field to nil concurrently; reading the captured local
// keeps the event loop safe through the race window.
w.mu.Lock()
fsw := w.watcher
w.mu.Unlock()
if fsw == nil {
return
}
for {
select {
case <-w.closedCh:
return
case ev, ok := <-fsw.Events:
if !ok {
return
}
if !w.eventRelevant(ev) {
continue
}
w.schedule()
case err, ok := <-fsw.Errors:
if !ok {
return
}
if err != nil {
w.logger.Debug(context.Background(), "context watcher error", slog.Error(err))
}
}
}
}
// eventRelevant filters out events that cannot affect any
// recognized resource. The check is conservative: any event on
// a directory triggers a re-resolve so newly created subtrees
// are picked up.
func (*Watcher) eventRelevant(ev fsnotify.Event) bool {
name := filepath.Base(ev.Name)
if recognizedInstructionFile(name) || name == mcpConfigFileName || name == skillMetaFileName {
return true
}
// Directory create/remove flips re-resolve so new subtrees
// arm watches and removed subtrees stop arming them.
if ev.Has(fsnotify.Create) || ev.Has(fsnotify.Remove) || ev.Has(fsnotify.Rename) {
return true
}
return false
}
// schedule arms or resets the debounce timer.
func (w *Watcher) schedule() {
w.mu.Lock()
if w.closed {
w.mu.Unlock()
return
}
cb := w.onChange
if w.timer != nil {
w.timer.Reset(w.debounce)
w.mu.Unlock()
return
}
w.timer = w.clock.AfterFunc(w.debounce, func() {
w.mu.Lock()
w.timer = nil
w.mu.Unlock()
cb()
})
w.mu.Unlock()
}
// collectDirs returns the set of directories to watch. Discovery
// is fixed-location, mirroring the resolver: for each scan root we
// watch the root directory itself (catching top-level instruction
// and .mcp.json changes), plus every existing skill container and
// its immediate skill subdirectories (catching skill add/remove
// and SKILL.md writes). The watcher never recurses the tree.
func (*Watcher) collectDirs(roots []ScanRoot) map[string]struct{} {
out := make(map[string]struct{})
for _, root := range roots {
if root.Path == "" {
continue
}
info, err := os.Stat(root.Path)
if err != nil {
// Watch the deepest existing ancestor so the
// root being created later still fires.
if ancestor := existingAncestor(root.Path); ancestor != "" {
out[ancestor] = struct{}{}
}
continue
}
if !info.IsDir() {
out[filepath.Dir(root.Path)] = struct{}{}
continue
}
out[root.Path] = struct{}{}
for _, container := range skillContainersFor(root.Path) {
out[container] = struct{}{}
entries, err := os.ReadDir(container)
if err != nil {
continue
}
for _, e := range entries {
if e.IsDir() {
out[filepath.Join(container, e.Name())] = struct{}{}
}
}
}
}
return out
}
// existingAncestor returns the deepest existing ancestor of
// path, or "" if no ancestor exists (e.g. an entirely missing
// drive on Windows).
func existingAncestor(path string) string {
cur := filepath.Dir(path)
for {
if cur == "" || cur == "." {
return ""
}
info, err := os.Stat(cur)
if err == nil && info.IsDir() {
return cur
}
parent := filepath.Dir(cur)
if parent == cur {
return ""
}
cur = parent
}
}