Files
coder/agent/agentcontext/watcher.go
T
Kyle Carberry cd3692c0c2 feat: add agent-side workspace context sources and Agent API v2.10 PushContextState (#25983)
Adds the agent half of the workspace context sources RFC. The agent now
resolves instruction files, skills, and MCP configs into a typed
`Snapshot`, watches the relevant paths recursively, exposes the source
list over a workspace-agent HTTP API, and pushes each `Snapshot` to
coderd over a new `PushContextState` RPC on Agent API v2.10.

The coderd-side handler is a stub returning `Unimplemented` for now.
Real persistence to `workspace_agent_context`, chatd hydration on dirty
events, and the `KindMCPServer` MCP provider are tracked by
[CODAGT-569](https://linear.app/codercom/issue/CODAGT-569/enable-agent-api-v210-pushcontextstate-bump-currentminor-wire-coderd).
This matches the pattern used for v2.7 `ReportBoundaryLogs` in
[#21293](https://github.com/coder/coder/pull/21293), which bumped the
version and shipped a stub server so the wire and client could iterate
before the persistence layer landed.

## What ships

### agent/agentcontext (new package)

- `Source`, `Resource` (kinds `instruction_file`, `skill`, `mcp_config`,
`mcp_server` plus reserved `plugin`/`hook`/`subagent`/`command`),
`ResourceStatus`, `Snapshot`, `ComputeAggregateHash`.
- `Manager` owns the in-memory source list, performs the initial resolve
synchronously in `NewManager`, runs a re-resolve/watcher loop in `Run`,
exposes
`AddSource`/`RemoveSource`/`Sources`/`HasSource`/`Snapshot`/`SubscribeChanges`/`Resync`/`SeedSources`/`Close`.
- `Resolver` walks scan roots, classifies recognized files, enforces 64
KiB per-resource, 2 MiB aggregate, and 500-resource caps with
`StatusOversize`/`StatusExcluded`/`StatusUnreadable`/`StatusInvalid`
outcomes, skips `node_modules`/`vendor`/etc., validates symlink targets
stay inside the scan root, stamps `SourcePath` on user-derived
resources, and optionally pulls MCP server tool lists via an
`MCPProvider` interface. MCP config resources ship metadata only (size,
hash) so secrets in env blocks never leave the agent.
- `Watcher` is a recursive `fsnotify` wrapper with a 250 ms debounce,
dynamic arming of newly created directories, and an ENOSPC-tolerant
degraded mode that no-ops further syncs until the manager resyncs
explicitly.
- HTTP API for `GET/POST /sources`, `GET/DELETE /sources/{path}`, `POST
/resync` mounted at `/api/v0/context`.
- `Pusher` interface plus `RunPush` goroutine with exponential backoff
capped at 30 s. `DRPCPusher` adapts the generated `DRPCAgentClient210`
to `Pusher` and translates `drpcerr.Unimplemented` to
`ErrPushUnimplemented` so the push loop exits cleanly when talking to
coderd deployments that have not enabled the real handler.

### agent/proto (v2.10)

- New messages `ContextResource`, `PushContextStateRequest`,
`PushContextStateResponse` and the `PushContextState` RPC on `service
Agent`.
- Generated `DRPCAgentClient210` interface and
`codersdk/agentsdk.Client.ConnectRPC210` / `ConnectRPC210WithRole`.
- `tailnet/proto.CurrentMinor` bumped from `9` to `10`.

### Agent wiring

- `agent.Options.Client` declares both v2.9 and v2.10 connectors;
`run()` dials with `ConnectRPC210WithRole`.
- `apiConnRoutineManager` holds a `DRPCAgentClient210`. Existing v2.8
routines keep their narrower `DRPCAgentClient28` signature thanks to
interface embedding.
- `startAgentAPI210` is the v2.10 counterpart to `startAgentAPI` for
routines that need the new client. The push context state routine uses
it.
- A `contextManager` is constructed in `agent.init()`, seeded from the
existing `CODER_AGENT_EXP_*_DIRS` env vars, started in its own goroutine
under `gracefulCtx`, and closed in `agent.Close`.
- `handleManifest` calls `Manager.SeedSources` for sources rooted at the
manifest directory, then `Resync` after `manifest.Swap`, so the snapshot
reflects the workspace working directory immediately instead of waiting
for the next filesystem event.
- HTTP routes mounted at `/api/v0/context` when the manager is up.

### Coderd stub

`coderd/agentapi/context.go` returns `drpcerr.Unimplemented` for
`PushContextState`. The real handler that persists
`workspace_agent_context` rows, hydrates chats, and emits dirty events
lives in CODAGT-569.

## Tests

24 tests across `agent/agentcontext` cover types, paths, resolver
behavior with file caps, skill containers, MCP secret omission, symlink
target validation, the recursive watcher firing on real fsnotify events,
manager source CRUD / `Resync` / `SeedSources` / `Run` lifetime, the
HTTP API, the DRPC adapter, and the push retry / initial-flag /
unimplemented paths. Passes `go test -race -count=2`.

`TestAgent_ContextStatePushed` boots a full agent against
`agenttest.FakeAgentAPI` (which now records `PushContextState` traffic)
and asserts the seeded `AGENTS.md` appears in a snapshot push with
`schema_version = 1`.

<details>
<summary>Notes for reviewers</summary>

- Source CRUD is workspace-agent-token only; coderd is not in the path
for source mutation.
- Per-resource cap 64 KiB, aggregate 2 MiB, count cap 500; resources
past the cap ship with `StatusExcluded` and an empty payload so the
aggregate hash still detects content edits. MCP-emitted resources
enforce both a per-provider count cap and the aggregate byte cap.
- Symlinks inside the scan root are followed; symlinks pointing outside
(or broken) are rejected with `StatusExcluded` so credentials reachable
via a stray symlink stay off the wire.
- The initial push gates `lifecycle = ready` in the eventual full
design. For this PR the `SeedSources` plus `handleManifest`-driven
`Resync` keeps the snapshot fresh; the live push loop ships now and
DRPCPusher translates the coderd `Unimplemented` stub into a clean exit.
- The `PLUGIN`/`HOOK`/`SUBAGENT`/`COMMAND` kinds are reserved in proto
and Go enums but unused; the Claude Code plugin resolver ships in a
follow-up that does not need a schema migration.
- Two follow-ups remain, both tracked by
[CODAGT-569](https://linear.app/codercom/issue/CODAGT-569/enable-agent-api-v210-pushcontextstate-bump-currentminor-wire-coderd):
(1) the chatd-side handler that persists snapshots and dirties chats;
(2) the `coder exp chat context` CLI command set for
`list`/`show`/`add`/`remove`/`refresh`.

</details>

_This PR was authored by Coder Agents on Kyle Carberry's behalf._
2026-06-08 12:08:40 -07:00

392 lines
10 KiB
Go

package agentcontext
import (
"context"
"errors"
"io/fs"
"os"
"path/filepath"
"strings"
"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 recursive watcher.
type WatcherOptions struct {
Logger slog.Logger
Clock quartz.Clock
Debounce time.Duration
// MaxDepth caps the recursion depth when discovering
// subdirectories to watch. Zero defaults to
// DefaultMaxScanDepth. Callers wiring the watcher to a
// Resolver should pass the resolver's MaxDepth so the
// watcher never misses edits below the scan horizon.
MaxDepth int
// 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 recursive fsnotify wrapper. fsnotify does not
// support recursive watches natively on Linux, so we walk every
// scan root at sync time and register each subdirectory
// individually. 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
maxDepth int
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()
}
maxDepth := opts.MaxDepth
if maxDepth <= 0 {
maxDepth = DefaultMaxScanDepth
}
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,
maxDepth: maxDepth,
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,
maxDepth: maxDepth,
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 a fresh
// recursive walk of every scan root. 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 recursive directory walk 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 (filepath.WalkDir on
// every scan root). Compute the desired set outside the
// mutex so a slow walk 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 walks every scan root and returns the set of
// directories to watch. The maximum depth uses the watcher's
// configured maxDepth so it mirrors the resolver's horizon.
func (w *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
}
// Walk the directory and collect every descendant
// directory up to the depth cap.
rootDepth := strings.Count(filepath.Clean(root.Path), string(os.PathSeparator))
_ = filepath.WalkDir(root.Path, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
if !d.IsDir() {
return nil
}
if _, skip := skipDirNames[d.Name()]; skip && path != root.Path {
return fs.SkipDir
}
if strings.Count(path, string(os.PathSeparator))-rootDepth > w.maxDepth {
return fs.SkipDir
}
out[path] = struct{}{}
return nil
})
}
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
}
}