mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
## 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 & 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.
700 lines
20 KiB
Go
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")
|