Bug Description
When an ACP session is resumed after WebSocket reconnection, the agent's run silently fails. The handle_prompt() returns end_turn immediately (by design for turn_complete capability), but the background run fails 5 minutes later with an MCP timeout — no session/update events are ever produced.
Root Cause
MCPManager._toolset_cache on the shared pool-level MCPManager is never invalidated between sessions.
When an agent shares the pool's MCPManager (_mcp_shared = True, which is the default when the agent has no agent-level MCP servers), the _toolset_cache: dict[str, MCPToolset] persists across session boundaries. On session resume:
- A new
SessionConnectionPool is created with fresh transports (correct)
agent._mcp_snapshot is updated with new McpConfigEntry (correct)
get_agentlet() calls self.mcp.as_capability(snapshot=..., session_pool=...)
as_capability() → _make_capability() checks _toolset_cache[client_id] first
- Cache hit → returns OLD
MCPToolset (which holds the OLD transport → OLD AcpMcpConnection → dead WebSocket)
- Agentlet's MCP toolset
__aenter__() tries to initialize via the dead connection → 300s timeout → McpError
Why the cache key collides
AcpMCPServerConfig.client_id = f"acp_{self.acp_id}" — deterministic across sessions. The same ACP server always maps to the same _toolset_cache key, so session resume always hits the stale cache entry.
Evidence (from production logs)
09:30:27 — Session resume: new MCP connections mcp_conn_4459a3ed, mcp_conn_1523435e created
09:30:28 — /fta-eval skill command executed (has_instructions=True)
09:30:28 — handle_prompt() returns {"stopReason":"end_turn"} immediately
09:35:28 — MCP timeout (300s): Error sending mcp/message to client connection_id=mcp_conn_57fde2f2 (ORIGINAL WebSocket, from 08:39:42!)
09:35:29 — NativeTurn execution failed: McpError: Internal error
The error uses mcp_conn_57fde2f2 — the OLDEST connection, not the fresh ones created at resume time.
Reproduction Test
tests/mcp_server/test_stale_mcp_connection.py — 5 tests, all passing:
test_session_resume_returns_stale_toolset_from_cache — Main reproduction: creates shared MCPManager, simulates session 1 (cache populated) → session 2 resume (fresh transport ignored, stale toolset returned)
test_acp_client_id_is_deterministic_across_sessions — Documents why cache key collides
test_session_pool_provides_fresh_transport_on_resume — Proves SessionConnectionPool is NOT the source of the bug
test_multiple_acp_servers_all_go_stale_on_resume — All ACP servers go stale, not just one
test_disconnect_all_clears_cache_but_not_called_on_resume — disconnect_all() would fix it, but session resume never calls it
Key Code Path
| File |
Line |
Role |
messagenode.py |
130-132 |
self.mcp = agent_pool.mcp when shared |
manager.py |
147 |
_toolset_cache: dict[str, Any] = {} |
manager.py |
374-394 |
_make_capability() — checks cache first, creates if missing |
manager.py |
414-425 |
Session-scoped configs still go through _make_capability() with caching |
session_pool.py |
198-219 |
add_transport() — correctly overwrites old transport in per-session pool |
session_manager.py |
264-286 |
get_or_create_session_agent() — creates fresh SessionConnectionPool |
session_controller.py |
932-947 |
close_session() — does NOT clear shared _toolset_cache |
mcp_server.py |
414-416 |
AcpMCPServerConfig.client_id = f"acp_{self.acp_id}" (deterministic) |
Proposed Fix
Don't cache session-scoped toolsets in the shared _toolset_cache.
In MCPManager._make_capability() (manager.py:374-394), add a cache parameter:
def _make_capability(server: BaseMCPServerConfig, transport: Any, *, cache: bool = True) -> MCP:
client_id = server.client_id
toolset = self._toolset_cache.get(client_id) if cache else None
if toolset is None:
toolset = MCPToolset(client=transport, **_make_kwargs(server))
if cache:
self._toolset_cache[client_id] = toolset
return MCP(url=_derive_url(server), local=toolset, ...)
Then in _process_snapshot(), pass cache=False for session-scoped configs:
# Session-scoped configs (session + skill) — don't cache, transports are per-session
for entry in snap.session_scoped_configs:
...
capabilities.append(_make_capability(server, transport, cache=False))
Why this approach:
- Minimal change — only affects session-scoped path, global configs still benefit from caching
- Correct semantics — session-scoped transports are per-session by definition; caching them in a shared manager is a category error
- No lifecycle hooks needed — doesn't require changes to session close/resume paths
disconnect_all() still works for global configs
Secondary Issues (address separately)
ACPSession.close() doesn't clean MCP state (session.py:795+) — Cleans env, signals, sys_prompts but does NOT touch _mcp_manager._connections, agent._session_connection_pool, or agent._mcp_snapshot
session_manager.resume_session() early-return (session_manager.py:244-249) — If session is in _acp_sessions, returns immediately, ignoring new mcp_servers parameter
- No WebSocket disconnect cleanup — No hook fires when ACP WebSocket drops
- MCP snapshot merge is append-only (
session.py:525-535) — Deduplicates by client_id but never prunes old entries
Acceptance Criteria
Related Issues
Bug Description
When an ACP session is resumed after WebSocket reconnection, the agent's run silently fails. The
handle_prompt()returnsend_turnimmediately (by design forturn_completecapability), but the background run fails 5 minutes later with an MCP timeout — nosession/updateevents are ever produced.Root Cause
MCPManager._toolset_cacheon the shared pool-level MCPManager is never invalidated between sessions.When an agent shares the pool's MCPManager (
_mcp_shared = True, which is the default when the agent has no agent-level MCP servers), the_toolset_cache: dict[str, MCPToolset]persists across session boundaries. On session resume:SessionConnectionPoolis created with fresh transports (correct)agent._mcp_snapshotis updated with newMcpConfigEntry(correct)get_agentlet()callsself.mcp.as_capability(snapshot=..., session_pool=...)as_capability()→_make_capability()checks_toolset_cache[client_id]firstMCPToolset(which holds the OLD transport → OLDAcpMcpConnection→ dead WebSocket)__aenter__()tries to initialize via the dead connection → 300s timeout →McpErrorWhy the cache key collides
AcpMCPServerConfig.client_id=f"acp_{self.acp_id}"— deterministic across sessions. The same ACP server always maps to the same_toolset_cachekey, so session resume always hits the stale cache entry.Evidence (from production logs)
The error uses
mcp_conn_57fde2f2— the OLDEST connection, not the fresh ones created at resume time.Reproduction Test
tests/mcp_server/test_stale_mcp_connection.py— 5 tests, all passing:test_session_resume_returns_stale_toolset_from_cache— Main reproduction: creates shared MCPManager, simulates session 1 (cache populated) → session 2 resume (fresh transport ignored, stale toolset returned)test_acp_client_id_is_deterministic_across_sessions— Documents why cache key collidestest_session_pool_provides_fresh_transport_on_resume— ProvesSessionConnectionPoolis NOT the source of the bugtest_multiple_acp_servers_all_go_stale_on_resume— All ACP servers go stale, not just onetest_disconnect_all_clears_cache_but_not_called_on_resume—disconnect_all()would fix it, but session resume never calls itKey Code Path
messagenode.pyself.mcp = agent_pool.mcpwhen sharedmanager.py_toolset_cache: dict[str, Any] = {}manager.py_make_capability()— checks cache first, creates if missingmanager.py_make_capability()with cachingsession_pool.pyadd_transport()— correctly overwrites old transport in per-session poolsession_manager.pyget_or_create_session_agent()— creates freshSessionConnectionPoolsession_controller.pyclose_session()— does NOT clear shared_toolset_cachemcp_server.pyAcpMCPServerConfig.client_id=f"acp_{self.acp_id}"(deterministic)Proposed Fix
Don't cache session-scoped toolsets in the shared
_toolset_cache.In
MCPManager._make_capability()(manager.py:374-394), add acacheparameter:Then in
_process_snapshot(), passcache=Falsefor session-scoped configs:Why this approach:
disconnect_all()still works for global configsSecondary Issues (address separately)
ACPSession.close()doesn't clean MCP state (session.py:795+) — Cleans env, signals, sys_prompts but does NOT touch_mcp_manager._connections,agent._session_connection_pool, oragent._mcp_snapshotsession_manager.resume_session()early-return (session_manager.py:244-249) — If session is in_acp_sessions, returns immediately, ignoring newmcp_serversparametersession.py:525-535) — Deduplicates byclient_idbut never prunes old entriesAcceptance Criteria
test_session_resume_returns_stale_toolset_from_cachepasses with fix (toolset2.client is transport_b, not transport_a)test_multiple_acp_servers_all_go_stale_on_resumepasses with fix (all toolsets use session-2 transports)test_mcpmanager_caching.pytestssession/updateevents normally (manual ACP test)Related Issues
session/load+session/resume, may affect this flow)