fix(mcp): bound agent_run provisioning stalls (#419)#443
Conversation
Two unbounded awaits turned agent_run/agent_explore dispatch stalls into ~30-min MCP client idle timeouts under multi-window load. - HeadlessAgentConnectionGate: bound acquire/waitForClearConnection with a deadline (default 120s, MCPTimeoutPolicy.headlessAgentConnectionAcquireDeadline) so a queued bootstrap fails fast instead of parking forever. Kills the process-wide amplifier where one stalled start blocked every subsequent start across windows. WindowStateComposition.make gains a workspaceSearchService injection seam (mirrors workspaceFileContextStore). - WorkspaceManagerViewModel.loadWorkspaceFolders: cap the search-readiness convergence loop (maxSearchIndexConvergenceRebuilds) and add a Task.isCancelled guard (the loop was uncancellable by construction — why the watchdog couldn't rescue it). On cap, break to the existing readiness decision; the keep-fresh listener continues catch-up. WorkspaceSearchService gains a DEBUG-only rebuildIndex transform hook for deterministic non-convergence tests. Tests: HeadlessAgentConnectionGateDeadlineTests, WorkspaceSearchIndexConvergenceCapTests. Refs repoprompt#419.
|
Re: scope — yes, "deadline + convergence caps" is the intended scope here; the git-pool foreground lane is deliberately deferred, not omitted. Three reasons: 1. The git-admission slice is no longer the hang vector. 2. This PR closes the residual non-git hang surface that the git fix exposed. #419's 3. The foreground lane is a complementary, distinct change. It targets foreground git throughput (work completing promptly under saturation) — #419's named root cause — whereas the admission deadline converts "hang" → "fail-fast" and this PR converts "non-git hang" → "bounded." A reserved/preemptive foreground lane is a deeper admission-fairness change that alters cross-window scheduling for all windows, not just the Suggested resolution: keep this PR scoped as the hang-bounding fix, and track the foreground lane as the core #419 work (separate follow-up PR, or directly under #419). If you'd rather close #419 in one move, I'm happy to expand this PR to add a reserved foreground lane to |
Summary
Bounds two unbounded awaits in
agent_run/agent_exploreprovisioning that turned dispatch stalls into ~30-minute MCP client idle timeouts under multi-window load (#419).HeadlessAgentConnectionGate— the bootstrap amplifierThe gate is a process-wide singleton that serializes every agent bootstrap, and its
acquirewait had no deadline. When one start's bootstrap stalled, every subsequent start across all windows queued indefinitely behind it — the "concurrent multi-window load" trigger. (Operationally corroborated: restarting only the MCP server didn't clear it; a full app restart was required — pinning the stuck state to the app process, exactly where this singleton lives.)acquire/acquireWithDiagnostics/waitForClearConnectionnow take a boundeddeadline(default 120s,MCPTimeoutPolicy.headlessAgentConnectionAcquireDeadline). On expiry the queued waiter resumes with.timedOut→ returnsacquired: false→ the runner tears the start down cleanly instead of parking forever.WindowStateComposition.makegains aworkspaceSearchServiceinjection seam (mirrors the existingworkspaceFileContextStoreseam) for deterministic tests.WorkspaceManagerViewModel.loadWorkspaceFolders— the convergence loopThe search-readiness convergence loop had no iteration cap and was uncancellable by construction (its guards checked hydration but never
Task.isCancelled— which is why the MCP execution watchdog's cancellation couldn't rescue the stall). Under multi-window catalog churn the livecatalogGenerationkeeps advancing, sorebuildIndexchased a moving target indefinitely.maxSearchIndexConvergenceRebuilds = 3cap + a!Task.isCancelledguard. On cap,breakto the existing.ready/.degradeddecision — safe becausestartKeepingFresh(subscribed before the loop) self-heals in the background andisStalereporting stays honest.WorkspaceSearchServicegains a DEBUG-onlysetRebuildIndexReportedGenerationTransformForTestinghook (idiomatic; mirrorssetSearchDidCaptureGenerationHandler) for deterministic non-convergence tests.loadRoot's directory walk is intentionally not bounded here — it is a finite computation already covered externally by the watchdog; only a dead mount would hang it, which a Swift cap does not fix.Design note
agent_runstays.lifecycleManagedCancellable(no dispatch watchdog) by deliberate contract (testAgentRunAndExploreUseLifecycleManagedExemption). This PR bounds the two specific unbounded awaits rather than reversing that contract; an inline watchdog wrap of the@MainActorprovisioning path was rejected as fighting the isolation.Tests
HeadlessAgentConnectionGateDeadlineTests— a queued acquire with a short deadline returnsfalseand the waiter is removed from the queue; uncapped it parks forever.WorkspaceSearchIndexConvergenceCapTests— a deterministic non-converging rebuild drives the loop; asserts.switched, elapsed < 10s, and exactrebuildIndexcount== 1 + maxSearchIndexConvergenceRebuilds.Both green on
main(post #437 target split — tests importRepoPromptApp). Red state re-confirmed onmain: the gate has nodeadlineand the convergence loop has no cap / noTask.isCancelledguard, so the fix is still needed. Regression suites green (ContextBuilderRunLifecycleTests10/10,WorkspaceSwitchRecoveryTests22/22).Follow-ups
verify-ledgerreportsmissing=2for these new methods (main itself is currentlymissing=22, so it's a curation tool, not a CI gate). Happy to add the two rows if you want them curated now.Refs #419.
🤖 Generated with Claude Code