mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
## What Live MCP servers and their tools now flow into the `agentcontext` snapshot and are pushed to coderd via `PushContextState`, stored alongside instruction files and skills. Previously the resolver's MCP seam was unimplemented, so live MCP tool lists never reached the pushed snapshot. `agentcontext` is now **fully self-contained** for MCP: it connects to the MCP servers declared in the `.mcp.json` files its own watcher already discovers, lists their tools, and emits `KindMCPServer` resources. It does **not** depend on or modify `agent/x/agentmcp` — that package is left pristine and keeps serving the agent's MCP HTTP API. The two MCP paths run independently, which means the legacy package can be deleted later without touching this code. ## How - **Self-contained runner** (`agentcontext/mcprunner.go`): a one-shot MCP client (connect → initialize → list tools → close) with its own `.mcp.json` parser. A Manager goroutine (`runMCPSync`) reloads it whenever the discovered `KindMCPConfig` `path:contenthash` set changes, then re-resolves so the new tools are published. Per-server connects run in parallel (bounded) with a per-server timeout; a server that fails to connect is recorded as a failure rather than aborting the batch. Each connect also force-kills its subprocess on close, because mcp-go's stdio `Close()` closes stdin and then blocks on `cmd.Wait()` with no kill — a server that ignores stdin-close would otherwise stall the whole reload loop. - **Resource production** (`agentcontext/mcp.go`): `buildMCPServerResources` turns the runner's non-blocking per-server snapshot into `KindMCPServer` resources. Connected servers carry their sorted tools (`StatusOK`); failed servers surface as `StatusUnreadable` issues instead of vanishing; connected-but-no-tools-yet are skipped until a later reload. The content hash is tool-set sensitive. The resolver consumes this through a plain `MCPResources func() []Resource` field (no `MCPProvider` interface). - **Tool names**: emitted exactly as the server reports them. Flattening into a single namespace (e.g. `server__tool`) is left to the control plane in the next step, since each resource already carries the server name. - **Drift**: MCP resources are excluded from the snapshot aggregate/drift hash (`driftResources`). MCP servers connect asynchronously after boot; without this, a server finishing its connect would dirty every hydrated chat even though nothing the user pinned changed. - **Wiring** (`agent.go`): the manager is given `ManagerOptions.MCPExecer`/`MCPUpdateEnv`; `agent/x/agentmcp` is untouched. - **Config validation**: a structurally broken `.mcp.json` surfaces as `StatusInvalid` rather than silently dropping all its servers. coderd already persists `mcp_server`/`mcp_config` resource bodies (including tools), so no coderd or proto changes were required. ## Testing - **Unit**: `buildMCPServerResources` (grouping/sort/skip/failed/hash sensitivity), MCP resources applied via the resolver seam, MCP exclusion from the aggregate hash, `.mcp.json` parsing (transport inference, env expansion), `toolInputSchema`, and `mcpConfigSet` change detection. - **Proto serialization** (`TestDRPCPusher_HappyPathSerializesAllFields`): a `KindMCPServer` resource (tools + input schema) round-trips through `PushContextState` into the `MCPServerBody` wire form, asserting the server name, tool name/description, and the decoded `input_schema`. - **Manager-level, real subprocess** (`TestManager_MCPServerToolsInSnapshot`): a `.mcp.json` points at a re-exec'd fake stdio MCP server; the runner connects it and its `echo` tool surfaces as a `KindMCPServer` resource in the Manager snapshot — the same snapshot pushed to coderd — exercising `runMCPSync` and the resolver wiring end to end. - **Regression** (`TestManager_MCPServerHangingCloseDoesNotStall`): the fake server ignores stdin-close; the test asserts its tool still surfaces, proving the runner force-kills the subprocess instead of stalling the reload. Verified to fail without the fix. - All pass under `-race`; `go build ./...`, `go vet`, and `golangci-lint` are clean on the touched packages. ## Scope / follow-ups This is the agent-side production+push half. The chatd consumer (reading the pinned MCP resources for prompt/tool injection, including any server-prefix flattening of tool names) and removing the legacy `workspaceMCPToolsCache` pull path remain follow-ups, per the RFC rollout. While both `agent/x/agentmcp` and `agentcontext` exist, stdio MCP servers are spawned by both; this is intentional and temporary until `agentmcp` is removed. <details> <summary>Implementation plan and decisions</summary> **Goal:** produce live MCP server resources (with tools) from `agentcontext` and push them to coderd. **Starting state (main):** proto (`PushContextState`, `MCPServerBody`, `MCPTool`), the drpc adapter, coderd storage (`workspace_agent_context_resources`, body kind `mcp_server`), and the resolver's MCP seam already existed; nothing implemented the seam or fed live tools into the snapshot. **Decision (agentcontext fully separate from agentmcp):** `agentcontext` starts and lists its own MCP servers using only the connect-and-list half of an mcp-go client, driven by the `.mcp.json` files its existing watcher discovers. It shares no state with `agent/x/agentmcp` and does not import it. Two earlier revisions of this branch were discarded: (1) relocating `agentmcp` into `agentcontext` (rejected — it duplicates config parsing and file watching `agentcontext` already does); (2) reading `agentmcp`'s cached server snapshot via new accessors (rejected — unnecessary coupling between two packages that should simply run independently while one is being retired). The temporary double-spawn of stdio servers is the accepted cost of keeping the two paths cleanly separated until `agentmcp` is removed. **Decision (no tool-name prefixing, no MCPProvider interface):** the agent pushes raw, unflattened data — server name plus verbatim tool names — and lets the control plane own any `server__tool` flattening. With a single self-contained producer, the `MCPProvider` interface was collapsed into a `func() []Resource` field on the resolver. **Invariants held:** no secrets (env/headers) in pushed resources, only server/tool metadata; MCP excluded from the drift hash; the seam is non-blocking so the resolver never stalls on MCP I/O. </details> --- *This PR was created by Coder Agents on behalf of @kylecarbs.*
695 lines
21 KiB
Go
695 lines
21 KiB
Go
package agentcontext
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"golang.org/x/xerrors"
|
|
|
|
"cdr.dev/slog/v3"
|
|
"github.com/coder/coder/v2/agent/agentexec"
|
|
"github.com/coder/quartz"
|
|
)
|
|
|
|
// ManagerOptions configures a Manager. Zero values get sensible
|
|
// defaults.
|
|
type ManagerOptions struct {
|
|
// Logger receives diagnostic messages. Required.
|
|
Logger slog.Logger
|
|
// Clock is the time source used for the watcher's
|
|
// debounce timer. Optional; defaults to quartz.NewReal().
|
|
Clock quartz.Clock
|
|
// WorkingDir is evaluated on every resolve, mirroring the
|
|
// existing agent convention. The result is used as a
|
|
// scan root.
|
|
WorkingDir func() string
|
|
// InitialSources seeds the Manager's source list at boot
|
|
// time. Sources from CODER_AGENT_EXP_*_DIRS env vars or
|
|
// startup scripts are layered here.
|
|
InitialSources []Source
|
|
// AllowedRoots restricts which paths may be added as
|
|
// sources at runtime. When empty the package falls back
|
|
// to [~, ~/.coder, ~/.claude] plus the working directory.
|
|
// Tests override this to exercise the validation logic
|
|
// directly; production callers leave it unset.
|
|
AllowedRoots []string
|
|
// Resolver, when non-nil, replaces the default resolver.
|
|
// Tests use this to inject MCP resources (via
|
|
// Resolver.MCPResources) and tighten caps.
|
|
Resolver *Resolver
|
|
// MCPExecer, when non-nil, enables the self-contained MCP
|
|
// runner: the Manager connects to the MCP servers declared
|
|
// in the .mcp.json files it discovers, lists their tools,
|
|
// and surfaces them as KindMCPServer resources in every
|
|
// snapshot. The runner uses this Execer to launch stdio MCP
|
|
// servers. It is ignored when the resolver already has an
|
|
// MCP provider (e.g. a test injecting one via Resolver).
|
|
MCPExecer agentexec.Execer
|
|
// MCPUpdateEnv optionally enriches the environment handed to
|
|
// stdio MCP servers (typically the agent's per-command env).
|
|
// Used only when MCPExecer is set; may be nil.
|
|
MCPUpdateEnv func([]string) ([]string, error)
|
|
// Debounce overrides the watcher's debounce window.
|
|
Debounce time.Duration
|
|
}
|
|
|
|
// Source is a user-declared scan root added to the agent's
|
|
// in-memory list via the HTTP API or boot-time env seeding.
|
|
// Identity is the canonical absolute path.
|
|
type Source struct {
|
|
// Path is the canonical absolute path (symlinks resolved,
|
|
// ~ expanded). Empty means the zero value.
|
|
Path string
|
|
}
|
|
|
|
// Manager orchestrates source CRUD, resolution, watching, and
|
|
// Pusher fan-out. Construct with NewManager; start its lifecycle
|
|
// goroutines with Run; tear down with Close.
|
|
type Manager struct {
|
|
logger slog.Logger
|
|
clock quartz.Clock
|
|
workingDir func() string
|
|
allowedRoots []string
|
|
resolver *Resolver
|
|
// mcpRunner, when non-nil, owns the agent's self-contained
|
|
// MCP connection lifecycle and feeds the resolver's MCP
|
|
// provider. runMCPSync (started by Run) drives its reloads.
|
|
mcpRunner *mcpRunner
|
|
debounce time.Duration
|
|
|
|
mu sync.Mutex
|
|
sources []Source
|
|
// sourceIndex maps canonical path -> position in sources
|
|
// for O(1) lookups during AddSource / RemoveSource.
|
|
sourceIndex map[string]int
|
|
|
|
// snapshot is the latest result of a resolver pass. It is
|
|
// replaced atomically under mu.
|
|
snapshot Snapshot
|
|
// version monotonically increases per resolve pass.
|
|
version uint64
|
|
// resolveEpoch increments at the start of every resolver
|
|
// pass that drops m.mu around the filesystem walk. Each
|
|
// pass captures the epoch it claimed; at publish time it
|
|
// compares its captured epoch against the current epoch and
|
|
// skips the publish if a newer pass has started, preventing
|
|
// an old walk's stale result from overwriting a newer one's
|
|
// fresh result at a higher version number.
|
|
resolveEpoch uint64
|
|
|
|
// subscribers receive a non-blocking signal whenever the
|
|
// snapshot changes. Subscribers must drain their channel
|
|
// promptly; the Manager drops sends to full channels.
|
|
subscribers map[chan struct{}]struct{}
|
|
|
|
// trigger fires when AddSource / RemoveSource / watcher
|
|
// observe a change.
|
|
trigger chan struct{}
|
|
|
|
// running tracks Run lifetime.
|
|
running bool
|
|
closed bool
|
|
closedCh chan struct{}
|
|
runDoneCh chan struct{}
|
|
runStartedCh chan struct{}
|
|
|
|
watcher *Watcher
|
|
}
|
|
|
|
// NewManager validates options, canonicalizes initial sources,
|
|
// performs the first resolver pass synchronously, and returns
|
|
// the resulting Manager. Run must be called separately to start
|
|
// the watcher and re-resolve goroutine.
|
|
func NewManager(opts ManagerOptions) *Manager {
|
|
clock := opts.Clock
|
|
if clock == nil {
|
|
clock = quartz.NewReal()
|
|
}
|
|
debounce := opts.Debounce
|
|
if debounce <= 0 {
|
|
debounce = DefaultWatchDebounce
|
|
}
|
|
resolver := opts.Resolver
|
|
if resolver == nil {
|
|
resolver = &Resolver{}
|
|
}
|
|
|
|
m := &Manager{
|
|
logger: opts.Logger,
|
|
clock: clock,
|
|
workingDir: opts.WorkingDir,
|
|
allowedRoots: append([]string(nil), opts.AllowedRoots...),
|
|
resolver: resolver,
|
|
debounce: debounce,
|
|
sources: make([]Source, 0),
|
|
sourceIndex: make(map[string]int),
|
|
subscribers: make(map[chan struct{}]struct{}),
|
|
trigger: make(chan struct{}, 1),
|
|
closedCh: make(chan struct{}),
|
|
runDoneCh: make(chan struct{}),
|
|
runStartedCh: make(chan struct{}),
|
|
}
|
|
|
|
// Enable the self-contained MCP runner unless the resolver
|
|
// already has a provider (tests inject one via Resolver). The
|
|
// runner connects to the .mcp.json servers the resolver
|
|
// discovers and surfaces their tools as KindMCPServer
|
|
// resources; runMCPSync (started in Run) drives its reloads.
|
|
// The provider must be wired before the eager first resolve
|
|
// below so the seam is present from the first snapshot.
|
|
if resolver.MCPResources == nil && opts.MCPExecer != nil {
|
|
m.mcpRunner = newMCPRunner(m.logger.Named("mcp"), opts.MCPExecer, opts.MCPUpdateEnv, m.Trigger)
|
|
resolver.MCPResources = func() []Resource {
|
|
return buildMCPServerResources(m.mcpRunner.Servers())
|
|
}
|
|
}
|
|
|
|
for _, s := range opts.InitialSources {
|
|
canonical, err := CanonicalizePath(s.Path)
|
|
if err != nil {
|
|
// Initial sources may not exist yet at boot
|
|
// time; log and skip rather than abort the
|
|
// agent.
|
|
m.logger.Warn(context.Background(),
|
|
"skipping invalid initial source",
|
|
slog.F("path", s.Path),
|
|
slog.Error(err))
|
|
continue
|
|
}
|
|
if _, ok := m.sourceIndex[canonical]; ok {
|
|
continue
|
|
}
|
|
m.sourceIndex[canonical] = len(m.sources)
|
|
m.sources = append(m.sources, Source{Path: canonical})
|
|
}
|
|
|
|
// First snapshot is computed eagerly. The push protocol
|
|
// requires a snapshot to be present before the agent signals
|
|
// lifecycle = ready, so callers can rely on Snapshot() being
|
|
// populated immediately after NewManager returns.
|
|
m.resolveLocked()
|
|
|
|
return m
|
|
}
|
|
|
|
// Run starts the watcher and the re-resolve goroutine. Run
|
|
// blocks until ctx is canceled or Close is called. It is safe
|
|
// to call Run at most once per Manager.
|
|
func (m *Manager) Run(ctx context.Context) error {
|
|
m.mu.Lock()
|
|
if m.running {
|
|
m.mu.Unlock()
|
|
return xerrors.New("agentcontext: Manager.Run called more than once")
|
|
}
|
|
if m.closed {
|
|
m.mu.Unlock()
|
|
return xerrors.New("agentcontext: Manager already closed")
|
|
}
|
|
m.running = true
|
|
close(m.runStartedCh)
|
|
m.mu.Unlock()
|
|
// Close any early-exit path so Close does not block on
|
|
// runDoneCh after Run already set running=true. The deferred
|
|
// close runs even when NewWatcher fails.
|
|
defer close(m.runDoneCh)
|
|
|
|
watcher, err := NewWatcher(WatcherOptions{
|
|
Logger: m.logger.Named("watcher"),
|
|
Clock: m.clock,
|
|
Debounce: m.debounce,
|
|
MaxDepth: m.resolver.MaxDepth,
|
|
OnChange: m.signal,
|
|
})
|
|
if err != nil {
|
|
// NewWatcher already falls back to degraded mode on
|
|
// init failure, so an actual error here is
|
|
// exceptional.
|
|
return xerrors.Errorf("create watcher: %w", err)
|
|
}
|
|
m.mu.Lock()
|
|
m.watcher = watcher
|
|
roots := m.scanRootsLocked()
|
|
m.mu.Unlock()
|
|
watcher.Sync(ctx, roots)
|
|
|
|
defer watcher.Close()
|
|
|
|
// Drive MCP server reloads from discovered .mcp.json files for
|
|
// the lifetime of Run. Started here (not in NewManager) so it
|
|
// runs alongside the trigger loop that consumes its re-resolve
|
|
// signals.
|
|
if m.mcpRunner != nil {
|
|
go m.runMCPSync(ctx)
|
|
}
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-m.closedCh:
|
|
return nil
|
|
case <-m.trigger:
|
|
m.mu.Lock()
|
|
roots := m.scanRootsLocked()
|
|
m.mu.Unlock()
|
|
watcher.Sync(ctx, roots)
|
|
m.resolveAndBroadcast(ctx)
|
|
}
|
|
}
|
|
}
|
|
|
|
// started returns a channel that is closed once Run has
|
|
// claimed the running flag. Tests use it to coordinate with
|
|
// the watcher loop without polling; a closed channel never
|
|
// blocks, so this is safe to call repeatedly.
|
|
func (m *Manager) started() <-chan struct{} {
|
|
return m.runStartedCh
|
|
}
|
|
|
|
// Close stops the Manager. Close is idempotent; subsequent
|
|
// calls block until Run exits.
|
|
func (m *Manager) Close() error {
|
|
m.mu.Lock()
|
|
if m.closed {
|
|
running := m.running
|
|
m.mu.Unlock()
|
|
if running {
|
|
<-m.runDoneCh
|
|
}
|
|
return nil
|
|
}
|
|
m.closed = true
|
|
running := m.running
|
|
close(m.closedCh)
|
|
m.mu.Unlock()
|
|
if running {
|
|
<-m.runDoneCh
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Sources returns a defensive copy of the current source list.
|
|
// The returned slice is safe to mutate.
|
|
func (m *Manager) Sources() []Source {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
out := make([]Source, len(m.sources))
|
|
copy(out, m.sources)
|
|
return out
|
|
}
|
|
|
|
// HasSource reports whether path matches an existing source
|
|
// after canonicalization. Returns the canonical path on
|
|
// success.
|
|
func (m *Manager) HasSource(path string) (canonical string, ok bool) {
|
|
c, err := CanonicalizePath(path)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
_, ok = m.sourceIndex[c]
|
|
return c, ok
|
|
}
|
|
|
|
// AddSource adds a new source. The path is canonicalized and
|
|
// validated against the AllowedRoots set. AddSource is
|
|
// idempotent.
|
|
func (m *Manager) AddSource(s Source) (Source, error) {
|
|
canonical, err := CanonicalizePath(s.Path)
|
|
if err != nil {
|
|
return Source{}, xerrors.Errorf("canonicalize: %w", err)
|
|
}
|
|
if err := ValidateSourcePath(canonical, m.effectiveAllowedRoots()); err != nil {
|
|
return Source{}, err
|
|
}
|
|
|
|
m.mu.Lock()
|
|
if _, ok := m.sourceIndex[canonical]; ok {
|
|
out := m.sources[m.sourceIndex[canonical]]
|
|
m.mu.Unlock()
|
|
return out, nil
|
|
}
|
|
m.sourceIndex[canonical] = len(m.sources)
|
|
m.sources = append(m.sources, Source{Path: canonical})
|
|
m.mu.Unlock()
|
|
|
|
m.signal()
|
|
return Source{Path: canonical}, nil
|
|
}
|
|
|
|
// SeedSources canonicalizes and inserts a batch of trusted
|
|
// sources without applying AllowedRoots validation. It is the
|
|
// late-binding equivalent of ManagerOptions.InitialSources for
|
|
// callers that need the working directory to resolve relative
|
|
// paths but only learn the working directory after Run has
|
|
// started. Paths that fail canonicalization are silently
|
|
// skipped, matching the boot-time seeding contract. SeedSources
|
|
// is idempotent: previously seeded canonical paths are
|
|
// deduplicated via the existing source index.
|
|
//
|
|
// AddSource is the correct entry point for untrusted HTTP
|
|
// callers; this method exists only for the agent's manifest-
|
|
// triggered seeding from CODER_AGENT_EXP_*_DIRS, where the
|
|
// template author already authorized the paths.
|
|
func (m *Manager) SeedSources(sources []Source) {
|
|
if len(sources) == 0 {
|
|
return
|
|
}
|
|
m.mu.Lock()
|
|
changed := false
|
|
for _, s := range sources {
|
|
canonical, err := CanonicalizePath(s.Path)
|
|
if err != nil {
|
|
m.logger.Warn(context.Background(),
|
|
"skipping invalid seeded source",
|
|
slog.F("path", s.Path),
|
|
slog.Error(err))
|
|
continue
|
|
}
|
|
if _, ok := m.sourceIndex[canonical]; ok {
|
|
continue
|
|
}
|
|
m.sourceIndex[canonical] = len(m.sources)
|
|
m.sources = append(m.sources, Source{Path: canonical})
|
|
changed = true
|
|
}
|
|
m.mu.Unlock()
|
|
if changed {
|
|
m.signal()
|
|
}
|
|
}
|
|
|
|
// RemoveSource removes the source matching path. Path is
|
|
// canonicalized before matching. Returns ErrSourceNotFound when
|
|
// no such source exists or when the path cannot be canonicalized.
|
|
func (m *Manager) RemoveSource(path string) error {
|
|
canonical, err := CanonicalizePath(path)
|
|
if err != nil {
|
|
// A path that does not canonicalize cannot match any
|
|
// existing source. Mirror HasSource semantics by
|
|
// reporting not-found rather than leaking the
|
|
// canonicalize error to API callers.
|
|
return ErrSourceNotFound
|
|
}
|
|
|
|
m.mu.Lock()
|
|
idx, ok := m.sourceIndex[canonical]
|
|
if !ok {
|
|
m.mu.Unlock()
|
|
return ErrSourceNotFound
|
|
}
|
|
// O(n) compaction is fine for the typical handful of
|
|
// user-added sources.
|
|
m.sources = append(m.sources[:idx], m.sources[idx+1:]...)
|
|
delete(m.sourceIndex, canonical)
|
|
for i := idx; i < len(m.sources); i++ {
|
|
m.sourceIndex[m.sources[i].Path] = i
|
|
}
|
|
m.mu.Unlock()
|
|
|
|
m.signal()
|
|
return nil
|
|
}
|
|
|
|
// Snapshot returns the latest Snapshot. The returned value is
|
|
// safe to share but shares the same Resources slice as the
|
|
// internal state; callers must not mutate it.
|
|
func (m *Manager) Snapshot() Snapshot {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return m.snapshot
|
|
}
|
|
|
|
// SubscribeChanges returns a buffered channel that receives a
|
|
// signal whenever the snapshot changes. The unsubscribe
|
|
// callback is safe to call from any goroutine and is
|
|
// idempotent.
|
|
func (m *Manager) SubscribeChanges() (<-chan struct{}, func()) {
|
|
ch := make(chan struct{}, 1)
|
|
m.mu.Lock()
|
|
m.subscribers[ch] = struct{}{}
|
|
m.mu.Unlock()
|
|
|
|
// OnceFunc returns a closure that runs the underlying
|
|
// function at most once. Subsequent invocations are no-ops,
|
|
// matching the idempotency contract callers rely on.
|
|
unsub := sync.OnceFunc(func() {
|
|
m.mu.Lock()
|
|
delete(m.subscribers, ch)
|
|
m.mu.Unlock()
|
|
// Don't close ch: readers may still be in flight.
|
|
})
|
|
return ch, unsub
|
|
}
|
|
|
|
// Resync forces an immediate re-resolve and returns the new
|
|
// Snapshot. Resync is safe to call regardless of whether Run is
|
|
// active. Like resolveAndBroadcast, Resync drops the Manager's
|
|
// mutex around the resolver pass so concurrent Sources,
|
|
// AddSource, RemoveSource, and Snapshot calls do not block on
|
|
// filesystem I/O. When the watcher is active, Resync also
|
|
// re-arms it so newly added scan roots are observed for
|
|
// subsequent edits.
|
|
func (m *Manager) Resync(ctx context.Context) (Snapshot, error) {
|
|
if ctxErr := ctx.Err(); ctxErr != nil {
|
|
return m.Snapshot(), ctxErr
|
|
}
|
|
|
|
m.mu.Lock()
|
|
if m.closed {
|
|
m.mu.Unlock()
|
|
return m.Snapshot(), ErrManagerClosed
|
|
}
|
|
roots := m.scanRootsLocked()
|
|
resolver := m.resolver
|
|
watcher := m.watcher
|
|
m.resolveEpoch++
|
|
myEpoch := m.resolveEpoch
|
|
m.mu.Unlock()
|
|
|
|
if ctxErr := ctx.Err(); ctxErr != nil {
|
|
return m.Snapshot(), ctxErr
|
|
}
|
|
snap := resolver.ResolveContext(ctx, roots)
|
|
if ctxErr := ctx.Err(); ctxErr != nil {
|
|
// Cancellation mid-walk yields a partial or empty
|
|
// Snapshot whose SnapshotError is set to
|
|
// "context canceled". Publishing it would replace
|
|
// the live Snapshot with empty resources until the
|
|
// next trigger, so bail without touching state.
|
|
return m.Snapshot(), ctxErr
|
|
}
|
|
if snap.SnapshotError == "" && watcher != nil {
|
|
if d := watcher.Degraded(); d != "" {
|
|
snap.SnapshotError = d
|
|
}
|
|
}
|
|
|
|
m.mu.Lock()
|
|
if m.closed {
|
|
m.mu.Unlock()
|
|
return m.Snapshot(), ErrManagerClosed
|
|
}
|
|
if m.resolveEpoch != myEpoch {
|
|
// A newer resolve pass started while this one was
|
|
// walking the filesystem. The newer pass's data
|
|
// strictly supersedes ours, so skip the publish to
|
|
// avoid overwriting a fresher Snapshot at a higher
|
|
// version. Return the currently published Snapshot,
|
|
// which is at least as fresh as ours. The watcher
|
|
// is NOT re-armed: the winning pass already synced
|
|
// with the current roots, and replaying our stale
|
|
// root set here would drop watches on sources that
|
|
// only the newer pass knows about.
|
|
published := m.snapshot
|
|
m.mu.Unlock()
|
|
return published, nil
|
|
}
|
|
m.version++
|
|
snap.Version = m.version
|
|
m.snapshot = snap
|
|
subs := make([]chan struct{}, 0, len(m.subscribers))
|
|
for ch := range m.subscribers {
|
|
subs = append(subs, ch)
|
|
}
|
|
m.mu.Unlock()
|
|
|
|
if watcher != nil {
|
|
watcher.Sync(ctx, roots)
|
|
}
|
|
|
|
// The broadcast is unconditional: Resync waiters that
|
|
// triggered the pass without an actual content change
|
|
// still need to wake up. Subscribers compare snapshots via
|
|
// AggregateHash if they want to filter.
|
|
for _, ch := range subs {
|
|
select {
|
|
case ch <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
return snap, nil
|
|
}
|
|
|
|
// signal triggers a re-resolve. Sends are non-blocking; the
|
|
// trigger channel has a depth of 1, which coalesces bursts.
|
|
func (m *Manager) signal() {
|
|
select {
|
|
case m.trigger <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
// Trigger queues an asynchronous re-resolve. Trigger returns
|
|
// immediately; the Run goroutine performs the filesystem walk
|
|
// in the background and broadcasts when it finishes. Use
|
|
// Trigger when the caller wants the watcher to pick up an
|
|
// updated working directory or scan-root set but does not need
|
|
// the new Snapshot synchronously. Trigger is a no-op when Run
|
|
// has not started or the Manager is closed.
|
|
func (m *Manager) Trigger() {
|
|
m.signal()
|
|
}
|
|
|
|
// scanRootsLocked returns the list of ScanRoots to feed the
|
|
// resolver and watcher. The Manager's mutex must be held.
|
|
func (m *Manager) scanRootsLocked() []ScanRoot {
|
|
builtinRoots := defaultBuiltinRoots()
|
|
out := make([]ScanRoot, 0, 1+len(builtinRoots)+len(m.sources))
|
|
if m.workingDir != nil {
|
|
if wd := strings.TrimSpace(m.workingDir()); wd != "" {
|
|
out = append(out, ScanRoot{Path: wd})
|
|
}
|
|
}
|
|
for _, r := range builtinRoots {
|
|
canonical, err := CanonicalizePath(r)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
out = append(out, ScanRoot{Path: canonical})
|
|
}
|
|
for _, s := range m.sources {
|
|
out = append(out, ScanRoot{Path: s.Path, UserSource: s.Path})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// effectiveAllowedRoots returns the AllowedRoots augmented
|
|
// with the current working directory. The working directory is
|
|
// evaluated on every call so it picks up the workspace's
|
|
// resolved path after the agent's manifest finishes loading.
|
|
// When AllowedRoots is empty the package falls back to its
|
|
// default policy ([~, ~/.coder, ~/.claude]).
|
|
func (m *Manager) effectiveAllowedRoots() []string {
|
|
var roots []string
|
|
if len(m.allowedRoots) > 0 {
|
|
roots = append(roots, m.allowedRoots...)
|
|
} else {
|
|
roots = append(roots, defaultAllowedRoots()...)
|
|
}
|
|
if m.workingDir != nil {
|
|
if wd := strings.TrimSpace(m.workingDir()); wd != "" {
|
|
roots = append(roots, wd)
|
|
}
|
|
}
|
|
return roots
|
|
}
|
|
|
|
// resolveAndBroadcast computes a fresh snapshot and notifies
|
|
// every subscriber. The broadcast is unconditional: Resync
|
|
// waiters that triggered the pass without an actual content
|
|
// change still need to wake up. Subscribers compare snapshots
|
|
// via AggregateHash if they want to filter.
|
|
func (m *Manager) resolveAndBroadcast(ctx context.Context) {
|
|
// Snapshot the inputs under the lock, then release it
|
|
// before running the resolver. The resolver walks the
|
|
// filesystem, reads files, and hashes them; holding
|
|
// m.mu across that would block Sources, AddSource,
|
|
// RemoveSource, Snapshot, and SubscribeChanges for the
|
|
// duration of the pass.
|
|
m.mu.Lock()
|
|
roots := m.scanRootsLocked()
|
|
resolver := m.resolver
|
|
watcher := m.watcher
|
|
m.resolveEpoch++
|
|
myEpoch := m.resolveEpoch
|
|
m.mu.Unlock()
|
|
|
|
if err := ctx.Err(); err != nil {
|
|
return
|
|
}
|
|
snap := resolver.ResolveContext(ctx, roots)
|
|
if err := ctx.Err(); err != nil {
|
|
// Cancellation mid-walk yields a partial or empty
|
|
// Snapshot. Publishing it would replace the live
|
|
// Snapshot with empty resources, so bail without
|
|
// touching state. The Run loop's gracefulCtx is
|
|
// canceled only at shutdown, but defensive checks
|
|
// keep the publish contract uniform with Resync.
|
|
return
|
|
}
|
|
// Surface watcher degradation as a snapshot-level error
|
|
// when the resolver did not already emit one.
|
|
if snap.SnapshotError == "" && watcher != nil {
|
|
if d := watcher.Degraded(); d != "" {
|
|
snap.SnapshotError = d
|
|
}
|
|
}
|
|
|
|
m.mu.Lock()
|
|
if m.resolveEpoch != myEpoch {
|
|
// A newer resolve pass started while this one was
|
|
// walking the filesystem. Skip the publish so a
|
|
// stale-epoch result does not overwrite a fresher
|
|
// Snapshot at a higher version number. The newer
|
|
// pass will broadcast its own result.
|
|
m.mu.Unlock()
|
|
return
|
|
}
|
|
m.version++
|
|
snap.Version = m.version
|
|
m.snapshot = snap
|
|
subs := make([]chan struct{}, 0, len(m.subscribers))
|
|
for ch := range m.subscribers {
|
|
subs = append(subs, ch)
|
|
}
|
|
m.mu.Unlock()
|
|
|
|
for _, ch := range subs {
|
|
select {
|
|
case ch <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
}
|
|
|
|
// resolveLocked runs the resolver inline while m.mu is held.
|
|
// It is used by the synchronous initial resolve in NewManager,
|
|
// where there is no concurrent reader. Background re-resolves
|
|
// must use resolveAndBroadcast, which drops the lock around
|
|
// filesystem I/O.
|
|
func (m *Manager) resolveLocked() {
|
|
roots := m.scanRootsLocked()
|
|
snap := m.resolver.Resolve(roots)
|
|
m.version++
|
|
snap.Version = m.version
|
|
// Surface watcher degradation as a snapshot-level error
|
|
// when the resolver did not already emit one.
|
|
if snap.SnapshotError == "" && m.watcher != nil {
|
|
if d := m.watcher.Degraded(); d != "" {
|
|
snap.SnapshotError = d
|
|
}
|
|
}
|
|
m.snapshot = snap
|
|
}
|
|
|
|
// ErrSourceNotFound is returned by RemoveSource when the
|
|
// requested path is not in the source list.
|
|
var ErrSourceNotFound = xerrors.New("source not found")
|
|
|
|
// ErrManagerClosed is returned by methods called after Close.
|
|
var ErrManagerClosed = xerrors.New("agentcontext: manager closed")
|