mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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._
392 lines
10 KiB
Go
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
|
|
}
|
|
}
|