Files
coder/agent/agentcontext/manager.go
T
Kyle Carberry 1742003785 fix(agent): gate workspace context collection until the agent is ready (#26715)
## Problem

Workspace context surfaced in chat (Coder Agents) is incomplete and racy
on a fresh boot:

- The context panel is missing personal skills (only repo-level skills
under `.claude/skills` show up).
- The MCP section lists `.mcp.json` files but no MCP servers are
registered.
- The Issues panel reports instruction files as unreadable, e.g.
`CLAUDE.md (file: unreadable)` and `.cursorrules (file: unreadable)`
with `symlink resolve: lstat .../AGENTS.md: no such file or directory`.

## Root cause

`agentcontext.Manager` collected and pushed context too eagerly:

- `NewManager` ran an eager resolve at agent `init()`.
- `RunPush` starts as a normal connection routine (`startAgentAPI210`)
with no lifecycle gating, so the first snapshot was pushed
(`Initial=true`) as soon as the agent API connected.

Both happened **before startup scripts finish** and before the lifecycle
reaches `ready`. At that point:

- `CLAUDE.md` / `.cursorrules` symlinks to `AGENTS.md` don't resolve
yet, so `EvalSymlinks` fails and the resolver emits `StatusUnreadable`
"symlink resolve" issues.
- Personal skills haven't synced yet, so they're missing.
- MCP servers connect via `mcpManager.Reload(...)` only **after**
`ready`, so only `.mcp.json` configs appear, with no servers.

That partial, error-laden snapshot is persisted by coderd and can
hydrate a chat.

## Fix

Gate `agentcontext.Manager` until the agent is ready, unconditionally:

- The Manager always starts gated. `NewManager` leaves the zero-value
(version 0) snapshot in place and never walks the filesystem; `RunPush`
withholds version-0 snapshots, so nothing reaches coderd.
- The agent calls `Manager.SetReady()` from the lifecycle transition in
`handleManifest`, right after startup scripts finish (`ready`, or
terminal `start_error` / `start_timeout` so a failed startup still
surfaces whatever context exists).
- On `SetReady`, the Manager performs the first real resolve (version 1)
and broadcasts it; `RunPush` ships it with `Initial=true`. Later changes
(MCP connect, skill edits) re-resolve and push as before.

Eager resolution before `ready` was the bug, not a mode worth
preserving, so the gate is always on rather than an opt-in option. This
aligns the agent-side push with chatd, which already waits for agent
readiness before loading context. No proto/coderd/DB changes: coderd
simply never receives a pre-ready snapshot.

<details>
<summary>Design notes &amp; decisions</summary>

- **Unconditional, not opt-in.** An earlier iteration added the gate as
an opt-in `ManagerOptions.GateUntilReady`. Since the eager
resolve-on-construct was the defect, the option, the eager first
resolve, and the now-dead `resolveLocked` helper were all removed; the
Manager is always gated until `SetReady`.
- **Version 0 is the pre-ready sentinel.** The gated placeholder is just
the zero-value snapshot (version 0); the first real resolve is version
1, so the push loop withholds anything at version 0. An earlier revision
carried a dedicated `Snapshot.Initializing` bool plus an HTTP `/resync`
field, but the push loop was the only consumer and nothing read the HTTP
field, so both were dropped.
- **Defer, don't retry symlinks.** Transient "unreadable" symlinks are
an artifact of collecting before checkout. Deferring until `ready` fixes
all three symptom classes at once and avoids masking genuine post-ready
errors (a broken symlink at `ready` is still reported).
- **Release on terminal startup states too** (`start_error`,
`start_timeout`), so a failed startup still surfaces whatever context
exists instead of gating forever. On reconnect the Manager instance is
reused and stays ready.

</details>

## Tests

- `agentcontext.TestManager_WithholdsCollectionUntilReady` simulates
collection running before startup finishes (broken `CLAUDE.md` /
`.cursorrules` -> `AGENTS.md` symlinks): asserts the gated snapshot is
the empty version-0 placeholder with no resources and no `unreadable`
issues, and that after `SetReady` (target now present) the inventory
resolves cleanly to a single instruction file with no spurious issues.
- `agentcontext.TestRunPush_WaitsForReady` asserts the push loop ships
nothing while gated even when content exists, then ships the full
inventory with `Initial=true` after `SetReady`.
- `agentcontext.TestManager_SetReadyIsIdempotent` covers the version-0
placeholder before ready, the single resolve to version 1 on `SetReady`,
and idempotency across repeated calls.
- Updated `agent.TestAgent_ContextStatePushed`: the first push now
already contains `AGENTS.md` with `Initial=true` and no `UNREADABLE`
resources (no pre-startup empty/partial push).

Validated on the changed packages: `go test -race
./agent/agentcontext/...`, `go test ./agent/ -run
TestAgent_ContextStatePushed`, `golangci-lint run`, `go vet`, `gofmt`
(all clean).

---
🤖 Generated by Coder Agents on behalf of @kylecarbs.
2026-06-25 14:17:50 -06:00

700 lines
20 KiB
Go

package agentcontext
import (
"context"
"strings"
"sync"
"time"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"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
// MCPCatalog, when non-nil, supplies the per-server MCP snapshot
// the Manager surfaces as KindMCPServer resources on every
// resolve. The agent injects the shared MCP engine's catalog here
// so discovery and execution use one set of server connections.
// It is ignored when the resolver already has an MCP provider
// (e.g. a test injecting one via Resolver).
MCPCatalog func() []MCPServerStatus
// 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
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{}
// ready gates collection. While false (until the first SetReady
// call) the Manager does not scan; Snapshot() returns the empty
// version-0 value, which the push loop never sends to coderd.
// Guarded by mu.
ready bool
// running tracks Run lifetime.
running bool
closed bool
closedCh chan struct{}
runDoneCh chan struct{}
runStartedCh chan struct{}
watcher *Watcher
}
// NewManager validates options and canonicalizes initial sources. The
// returned Manager is gated, so its first snapshot is the empty
// version-0 placeholder and the first real resolve runs on SetReady.
// Call Run 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{}),
}
// Surface the shared MCP engine's catalog as KindMCPServer
// resources unless the resolver already has a provider (tests
// inject one via Resolver). The engine owns the connection
// lifecycle and notifies this Manager via Trigger when its
// catalog changes (see agent wiring). Wire it before SetReady runs
// the first resolve.
if resolver.MCPResources == nil && opts.MCPCatalog != nil {
resolver.MCPResources = func() []Resource {
return buildMCPServerResources(opts.MCPCatalog())
}
}
for _, s := range opts.InitialSources {
identity, err := lexicalPath(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
}
m.addSourceLocked(identity)
}
// Start gated: m.snapshot stays the zero value (version 0) until
// SetReady runs the first resolve.
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,
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()
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 a registered source,
// returning its lexical identity.
func (m *Manager) HasSource(path string) (canonical string, ok bool) {
c, err := lexicalPath(path)
if err != nil {
return "", false
}
m.mu.Lock()
defer m.mu.Unlock()
_, ok = m.sourceIndex[c]
return c, ok
}
// AddSource validates a new source against the AllowedRoots set
// and registers it by its lexical identity. AddSource is
// idempotent.
func (m *Manager) AddSource(s Source) (Source, error) {
identity, err := lexicalPath(s.Path)
if err != nil {
return Source{}, xerrors.Errorf("canonicalize: %w", err)
}
// Validate the resolved path so symlinks can't escape the allowed roots.
resolved, err := CanonicalizePath(s.Path)
if err != nil {
return Source{}, xerrors.Errorf("canonicalize: %w", err)
}
if err := ValidateSourcePath(resolved, m.effectiveAllowedRoots()); err != nil {
return Source{}, err
}
m.mu.Lock()
if idx, ok := m.sourceIndex[identity]; ok {
out := m.sources[idx]
m.mu.Unlock()
return out, nil
}
m.sourceIndex[identity] = len(m.sources)
m.sources = append(m.sources, Source{Path: identity})
m.mu.Unlock()
m.signal()
return Source{Path: identity}, nil
}
// SeedSources registers a batch of trusted sources without
// AllowedRoots validation, the late-binding equivalent of
// ManagerOptions.InitialSources for when the working directory
// is only known after Run starts. Invalid paths are skipped and
// duplicates are ignored.
//
// Untrusted callers must use AddSource; SeedSources exists only
// for manifest-triggered seeding from CODER_AGENT_EXP_*_DIRS.
func (m *Manager) SeedSources(sources []Source) {
if len(sources) == 0 {
return
}
m.mu.Lock()
changed := false
for _, s := range sources {
identity, err := lexicalPath(s.Path)
if err != nil {
m.logger.Warn(context.Background(),
"skipping invalid seeded source",
slog.F("path", s.Path),
slog.Error(err))
continue
}
if m.addSourceLocked(identity) {
changed = true
}
}
m.mu.Unlock()
if changed {
m.signal()
}
}
// RemoveSource removes the source matching path by its lexical
// identity, returning ErrSourceNotFound if none matches.
func (m *Manager) RemoveSource(path string) error {
identity, err := lexicalPath(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[identity]
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, identity)
for i := idx; i < len(m.sources); i++ {
m.sourceIndex[m.sources[i].Path] = i
}
m.mu.Unlock()
m.signal()
return nil
}
// addSourceLocked registers identity unless already present,
// reporting whether it was added. m.mu must be held.
func (m *Manager) addSourceLocked(identity string) bool {
if _, ok := m.sourceIndex[identity]; ok {
return false
}
m.sourceIndex[identity] = len(m.sources)
m.sources = append(m.sources, Source{Path: identity})
return true
}
// 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
}
if !m.ready {
// Gated until SetReady: return the version-0 placeholder, no scan.
snap := m.snapshot
m.mu.Unlock()
return snap, nil
}
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()
}
// SetReady starts context collection: the agent calls it once startup
// scripts finish (or terminally fail) so context is never collected
// from a half-built workspace. Idempotent; the first call triggers the
// first resolve and push.
func (m *Manager) SetReady() {
m.mu.Lock()
if m.ready || m.closed {
m.mu.Unlock()
return
}
m.ready = true
running := m.running
m.mu.Unlock()
if running {
// The Run loop owns the watcher; signal it to re-sync and resolve
// with ready=true.
m.signal()
return
}
// No Run loop yet (embedders or tests driving the Manager directly):
// resolve inline.
m.resolveAndBroadcast(context.Background())
}
// 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 != "" {
// The working directory is a single scan root. The
// resolver reads its top-level instruction files and
// .mcp.json plus the fixed skill containers under it;
// it neither descends into subdirectories nor climbs
// to parent directories. Additional directories are
// added explicitly as Sources or via the seeding env
// vars.
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()
if !m.ready {
// Gated until SetReady: no scan, no broadcast.
m.mu.Unlock()
return
}
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:
}
}
}
// 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")