Repository navigation
perf: parallelize toolset startup and prefetch MCP catalog - #3688
Conversation
docker-agent
left a comment
There was a problem hiding this comment.
Assessment: 🟢 APPROVE
The parallelisation and prefetch changes are well-structured. concurrent.MapSlice/ForEach are correct — Go 1.26.5 gives per-iteration loop variable semantics so there is no closure capture race. The two-wave start ordering (independent → peer-dependent) correctly preserves the invariant that the deferred aggregator starts only after its source toolsets have settled. gateway.Prefetch is safe: loader.load immediately wraps its fetch in context.WithoutCancel, so the background goroutine completes even if the caller's context is cancelled. The interactiveOAuthMu locking order (per-transport oauthFlowMu → global interactiveOAuthMu) is consistent at every call-site, ruling out deadlock.
Lower-confidence findings (not posted inline)
- [medium] pkg/tools/mcp/oauth.go:492 —
interactiveOAuthMu.Lock()does not honour context cancellation (confidence: 🟠 weak 52/100): In the interactive-prompt path (ensureToolSetsAreStarted), multiple toolsets starting concurrently could each queue oninteractiveOAuthMu. Async.Mutexlock is not cancellable, so if the agent run context is cancelled while a goroutine waits for the mutex, that goroutine remains blocked until the in-progress OAuth flow finishes. In practice this is the intended serialisation behaviour and the goroutine is not permanently leaked (it will proceed once the mutex is released and then return quickly on the cancelled context); the non-interactive startup path (emitToolsProgressively) already guards this withWithoutInteractivePromptsso the mutex is never contended there. Mentioning for awareness — a context-aware mutex (or aTryLockwith ctx-done select) would make cancellation instantaneous, but it is not required for correctness.
docker-agent
left a comment
There was a problem hiding this comment.
Assessment: 🟡 NEEDS ATTENTION
This PR's concurrent toolset startup is well-structured — the two-wave approach (independents first, then PeerDependent toolsets) is sound, concurrent.MapSlice/ForEach is correctly implemented, and the wrapper chain for PeerDependent detection works because all wrapper types implement Unwrapper. One liveness concern was found in the new OAuth serialization mutex.
| // Queue behind other transports' flows only after the cheap decline | ||
| // short-circuit; flows on this transport are already serialized by | ||
| // oauthFlowMu, so the latch cannot change while we wait here. | ||
| interactiveOAuthMu.Lock() |
There was a problem hiding this comment.
[medium] interactiveOAuthMu.Lock() is not context-aware: a cancelled toolset blocks indefinitely waiting behind an active OAuth flow
Trigger: This PR introduces concurrent toolset startup. If toolset A is in an interactive OAuth flow (waiting for user browser interaction, which can take minutes), toolset B — started concurrently — can also reach startInteractiveFlowLocked and block at interactiveOAuthMu.Lock() on line 492. If toolset B's ctx is cancelled before it acquires the mutex (e.g. the agent session is torn down, a startup timeout fires, or the user cancels), sync.Mutex.Lock() is not context-aware and cannot be interrupted. Toolset B's goroutine is stuck behind the user interaction for toolset A.
Impact: While blocked at interactiveOAuthMu.Lock(), toolset B's goroutine still holds oauthFlowMu (acquired by the caller authorizeOnce). Every concurrent RPC to toolset B's transport that also needs oauthFlowMu is therefore also stalled — potentially for minutes — even though toolset B's context is already cancelled. Once toolset B finally acquires interactiveOAuthMu, it calls handleOAuthFlow(ctx, ...) and returns immediately (cancelled context), but the delay was already paid. Before this PR sequential startup meant only one toolset could be doing OAuth at a time, so this scenario could not arise.
Suggested fix: Replace the bare sync.Mutex with a context-aware acquire that selects on ctx.Done():
acquired := make(chan struct{}, 1)
go func() { interactiveOAuthMu.Lock(); acquired <- struct{}{} }()
select {
case <-acquired:
defer interactiveOAuthMu.Unlock()
case <-ctx.Done():
return ctx.Err()
}Or use a sync.Cond-based approach, or a channel-based semaphore, to allow waiting goroutines to be preempted on context cancellation.
| Confidence | Score |
|---|---|
| 🟢 strong | 97/100 |
The first ref: toolset creation paid a blocking network round-trip to resolve the MCP catalog in the middle of LoadTeam. Kick the memoized fetch off in the background as soon as the config shows a catalog ref, so it overlaps model and environment resolution.
The startup sidebar pass started each toolset sequentially, so an agent with several MCP servers paid the sum of their handshakes before its tools appeared. Start them all concurrently (the context is already non-interactive, so no start can block on a user-driven flow), then process results in configuration order to keep progress events and once-per-streak warnings deterministic.
ensureToolSetsAreStarted ran Start() sequentially, so the first turn of an agent with several MCP toolsets (e.g. a sub-agent reached via transfer_task) paid the sum of the handshakes. Start them concurrently: Start() is single-flight per toolset, and warnings keep being recorded in configuration order after all starts settle.
A remote MCP tools/list is a network round-trip; listing sequentially made a turn pay the sum of them. List all started toolsets concurrently and merge in configuration order so duplicate handling (first toolset in config wins), collision reporting and once-per-streak warnings are unchanged.
Five call sites (toolset start/list, startup sidebar pass, hook dispatch, tool-call batches) hand-rolled the same pattern: a results slice indexed by i, one goroutine per element, WaitGroup. MapSlice captures it once — apply f to every element concurrently and return results in input order.
The deferred aggregator's Start lists its source toolsets' tools, so the concurrent fan-outs could race it against the very toolsets it depends on, yielding a spurious warning and missing deferred tools on the first turn. Mark such toolsets with tools.PeerDependent and start them in a second wave in both ensureToolSetsAreStarted (via new concurrent.ForEach) and emitToolsProgressively. emitToolsProgressively also consumes start outcomes in configuration order again instead of a barrier, so an early fast toolset is counted in the sidebar while a slow later one is still starting.
Toolsets now start concurrently, so two OAuth-requiring MCP servers could prompt the user simultaneously, race to bind a fixed callback port, and break legacy clients that answer elicitations without an elicitation_id (resolveSingle refuses ambiguous pending requests). A process-wide mutex around the interactive flow restores the one-at-a- time behavior of sequential startup; the per-transport sticky-decline short-circuit stays ahead of the queue so declined toolsets never wait.
f5ea16a to
babb9e1
Compare
docker-agent
left a comment
There was a problem hiding this comment.
Assessment: 🟡 NEEDS ATTENTION
Two LIKELY findings in the concurrency changes introduced by this PR. Neither is a crash or data-loss bug, but both represent new regressions vs. the sequential-startup behaviour this PR replaces.
| // Queue behind other transports' flows only after the cheap decline | ||
| // short-circuit; flows on this transport are already serialized by | ||
| // oauthFlowMu, so the latch cannot change while we wait here. | ||
| interactiveOAuthMu.Lock() |
There was a problem hiding this comment.
[medium] Holding per-transport oauthFlowMu while blocking on global interactiveOAuthMu can stall concurrent RPC calls for the duration of another transport's interactive OAuth flow
authorizeOnce acquires t.oauthFlowMu, then calls startInteractiveFlowLocked, which blocks on the process-wide interactiveOAuthMu. Before this PR, toolsets started sequentially, so Transport A's OAuth would never begin while Transport B's user-facing flow was in progress. Now that toolsets start concurrently, Transport A can be holding its own per-transport t.oauthFlowMu while waiting for Transport B's user to click "Authorize" in the browser — a wait measured in minutes.
During that entire window, any concurrent RoundTrip call for Transport A that encounters a 401 and calls authorizeOnce will block on t.oauthFlowMu with its request context ticking down, potentially timing out. The comment in the code acknowledges this lock ordering but does not address the extended hold duration introduced by concurrency.
A low-risk mitigation: acquire interactiveOAuthMu before t.oauthFlowMu (i.e. restructure authorizeOnce to try the cheap token-check first without holding oauthFlowMu, and only take t.oauthFlowMu for the minimal critical section). Alternatively, document that callers must use a context with a timeout long enough to survive a full interactive flow.
| Confidence | Score |
|---|---|
| 🟡 moderate | 75/100 |
| // instead of paying the network round-trip on the caller's critical path. | ||
| func Prefetch(ctx context.Context) { | ||
| loader := loaderFrom(ctx) | ||
| go func() { _, _ = loader.load(ctx) }() |
There was a problem hiding this comment.
[low] Prefetch winning the sync.Once race with a transient error permanently prevents catalog loading for this process lifetime
Prefetch fires go func() { _, _ = loader.load(ctx) }() in the background. Inside load, sync.Once memoizes the first result — including errors — so a transient failure during the prefetch window permanently caches the error; no subsequent ServerSpec call can retry.
Before this PR, Prefetch did not exist, so the once.Do was triggered only by a real ServerSpec call (at which point the caller could propagate the error and the user could retry by restarting). Now the once.Do fires at load time, in the background, when the caller has no visibility.
fetchAndCache does have a disk-cache fallback, so a pure network blip is unlikely to produce an error (it would return the cached catalog instead). The residual scenario is: no network AND no on-disk cache (cold start on first run with no connectivity). In that case, the process must be restarted to retry, whereas before this PR the first real call would fail immediately and the user could react.
Consider returning the error through a channel or logging it at WARN level so the failure is visible, and/or not entering once.Do from the background goroutine (only trigger the fetch eagerly without committing the error result — keep once.Do solely on the real call path).
| Confidence | Score |
|---|---|
| 🟡 moderate | 62/100 |
docker-agent 1.111.0 Created-by: HarmonybrewBot Commit-by: HarmonybrewBot Merged-by: HarmonybrewBot Description: Created by `brew bump` --- Created with `brew bump-formula-pr`.<details> <summary>release notes</summary> <pre>This release adds a new scheduler toolset, inline Mermaid diagram rendering, model switching in the lean TUI, project config autodiscovery, and a range of bug fixes and performance improvements across the agent, TUI, and server. ## What's New - Adds a built-in `scheduler` toolset with `create_schedule`, `list_schedules`, and `cancel_schedule` tools, enabling agents to schedule instructions to run once at a time, after a delay, or on a recurring interval - Adds prompt cache miss warnings via an opt-in `warn_on_cache_miss` user setting that emits notifications when cached input tokens are absent after the first session response - Adds inline Mermaid diagram rendering in the TUI - Adds model switching (`/model`) to the lean TUI and shares fuzzy model search between the lean and full TUIs - Adds autodiscovery of `docker-agent.yaml`, `docker-agent.yml`, and `docker-agent.hcl` project config files for no-argument local `docker agent run` - Surfaces background-job elicitation (user-input) requests in the TUI, ensuring each request opens exactly one dialog regardless of which runtime delivers it - Makes a custom `base_url` on a model automatically imply `bypass_models_gateway: true`, removing the need to set it explicitly ## Improvements - Improves session cost details in the TUI: aligns layout, color-codes cost percentages per message, groups averages together, and puts "Total" on its own line - Speeds up model switcher credential discovery by deduplicating credential names and resolving lookups concurrently, reducing model-picker latency - Parallelizes toolset startup and prefetches the MCP catalog off the load critical path, reducing agent startup time ## Bug Fixes - Fixes glob wildcard matching in permissions so `*` and `?` span path separators (`/`) in argument values such as file paths and URLs - Fixes a bug where setting `defer_all: true` on a toolset caused the toolset's custom `Instructions` to be permanently dropped from the agent's context - Fixes `SafetyPolicy` not persisting across turns, causing users to be re-prompted for tool approvals they had already opted into - Fixes background elicitations over the API so requests from concurrent background jobs are replayable and answerable instead of being auto-declined - Fixes Anthropic cache breakpoints exceeding the hard limit of 4 when deferred tools are present, which caused API errors at runtime - Fixes the TUI editor not regaining focus after external editing (`Ctrl+G`), which caused Enter to route to the transcript instead of sending the composed message - Fixes the scheduler schema so `type: scheduler` is accepted in agent config files (was previously rejected by JSON schema validation) - Fixes foreground elicitations being delivered twice into the app event stream - Fixes a mutex unlock in the scheduler's `setRuntime` method to use `defer` for safer lock release - Fixes VCR cassette path normalization for portable prompt-file matching - Fixes CI workflow YAML indentation that caused all CI runs to fail before any job could start ## Technical Changes - Adds `MapSlice` fan-out helper in the concurrent utilities package and uses it for parallel toolset operations - Serializes interactive OAuth flows across toolsets to prevent concurrent conflicts - Brings the documentation portal to WCAG 2.1 AA conformance across light and dark themes and adds a pa11y-ci CI gate to maintain it - Adds new documentation pages for the scheduler toolset, sandbox templates, custom commands, user settings, context and compaction guide, and fixes stale command syntax in the Named Commands docs --- ## What's Changed * docs: update CHANGELOG.md for v1.110.0 by @docker-read-write[bot] in docker/docker-agent#3665 * fix: repair CI workflow YAML and failing AgentsMd e2e test on main by @dgageot in docker/docker-agent#3666 * feat(tools): add "scheduler" toolset to run instructions on a time or recurring interval by @dwin-gharibi in docker/docker-agent#3632 * fix(config): add scheduler toolset to agent-schema.json type enum by @aheritier in docker/docker-agent#3670 * feat: custom base_url implies bypass_models_gateway by @dgageot in docker/docker-agent#3667 * feat: add prompt cache miss warnings by @rumpl in docker/docker-agent#3671 * Improve session cost details by @rumpl in docker/docker-agent#3672 * perf: speed up model switcher credential discovery by @rumpl in docker/docker-agent#3673 * Add model switching to the lean TUI by @rumpl in docker/docker-agent#3674 * feat(tui,app): surface & correlate background-job elicitations (#3584) by @aheritier in docker/docker-agent#3624 * chore: bump go-isatty, openai-go, and libopenapi by @dgageot in docker/docker-agent#3675 * ci: bump golangci-lint-action to v9.3.0 and fix DeferMutexUnlock lint offense by @dgageot in docker/docker-agent#3676 * ci: bump pinned actions to latest same-major versions by @dgageot in docker/docker-agent#3677 * feat(server): session-scoped elicitation sink for API/server runtimes (#3584) by @aheritier in docker/docker-agent#3625 * Render Mermaid diagrams inline by @rumpl in docker/docker-agent#3679 * fix(server): surface background elicitations over API by @Sayt-0 in docker/docker-agent#3678 * fix(teamloader): preserve deferred toolset instructions by @Piyush0049 in docker/docker-agent#3680 * fix(permissions): match glob wildcards across path separators by @parveshsaini in docker/docker-agent#3605 * docs(agents): fix stale command syntax and sub_agents claim in Named Commands by @aheritier in docker/docker-agent#3684 * docs(sandbox): document Docker Sandboxes integration and published sbx templates by @aheritier in docker/docker-agent#3682 * docs: surface orphan pages and add compaction, commands, and user-settings docs by @aheritier in docker/docker-agent#3683 * fix(docs): WCAG 2.1 AA accessibility remediation for the docs portal by @aheritier in docker/docker-agent#3687 * fix(tui): refocus the editor after external editing so Enter sends the content by @dgageot in docker/docker-agent#3690 * Implements the path-aware, two-tier documentation accessibility CI gate by @aheritier in docker/docker-agent#3691 * feat(run): autodiscover project agent config by @HajimohammadiNet in docker/docker-agent#3681 * perf: parallelize toolset startup and prefetch MCP catalog by @dgageot in docker/docker-agent#3688 * fix(anthropic): keep cache breakpoints within Anthropic's limit of 4 with deferred tools by @dgageot in docker/docker-agent#3692 * fix(docs): render --link-hover on light-mode callout-link hover by @aheritier in docker/docker-agent#3693 * build(deps): bump the pip group across 1 directory with 2 updates by @dependabot[bot] in docker/docker-agent#3697 * fix(session): persist SafetyPolicy across turns by @trungutt in docker/docker-agent#3685 ## New Contributors * @parveshsaini made their first contribution in docker/docker-agent#3605 * @HajimohammadiNet made their first contribution in docker/docker-agent#3681 **Full Changelog**: docker/docker-agent@v1.110.0...v1.111.0 </pre> <p>View the full release notes at <a href="https://github.com/docker/docker-agent/releases/tag/v1.111.0">https://github.com/docker/docker-agent/releases/tag/v1.111.0</a>.</p> </details> <hr> See merge request: Harmonybrew/homebrew-core!14465
Profiling
examples/gopher.yamlon Apple Silicon revealed that agent startup blocked sequentially on MCP handshakes: every toolset was started one after another, so total startup time was the sum of all handshake latencies. For a sub-agent likelibrarianthat reaches three MCP servers, that summed to around 5.2 s on a warm cache.This PR attacks that sequential work at every layer.
gateway.Prefetch()kicks off the memoized catalog fetch in the background as soon as a config withref:toolsets is loaded, so the network round-trip overlaps model and environment resolution instead of blockingLoadTeam. InsideEmitStartupInfo,agent.Run, andtools/list, toolsets are now started and queried concurrently using a newconcurrent.MapSlicefan-out helper that replaces five hand-rolled results-slice +WaitGrouppatterns (including pre-existing ones inpkg/hooks/executor.goandpkg/runtime/toolexec/dispatcher.go). Merged tool lists remain in configuration order so dedup, collision detection, and warning semantics are unchanged.Two correctness fixes accompany the parallelism. The
deferredaggregator implements a newtools.PeerDependentmarker so it is started only after all its peer toolsets have settled — preserving the ordering invariant that existed before when startup was sequential. A process-wide mutex inpkg/tools/mcp/oauth.goserializes interactive OAuth flows so concurrent starts cannot race on the fixed callback port, issue simultaneous prompts, or confuse legacy MCP clients that answer elicitations without anelicitation_id.Measured on Apple Silicon, warm caches:
LoadTeamdrops from ~150–190 ms to ~130–145 ms; gopher root-agent tools ready in 1.34 s (from 1.47 s, now bound by the inherent ~1.3 sgoplsMCP handshake); librarian sub-agent first turn drops from 5.2 s to ~3.4 s. The single-flight guarantee onStartis preserved, configuration-order warnings and dedup are preserved, and the deferred toolset ordering invariant is preserved.