Skip to content

spec: MCP session lifecycle fix — Phase 1 - #122

Merged
Leoyzen merged 30 commits into
develop/agenticfrom
fix/mcp-session-lifecycle
Jul 8, 2026
Merged

spec: MCP session lifecycle fix — Phase 1#122
Leoyzen merged 30 commits into
develop/agenticfrom
fix/mcp-session-lifecycle

Conversation

@Leoyzen

@Leoyzen Leoyzen commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

Summary

OpenSpec change for fixing stale MCP toolset cache and session-scoped resource lifecycle bugs identified in #121.

Problem

Session-scoped MCP resources (toolsets, transports, ACP connections) are never cleaned up when sessions close or WebSocket connections drop. The root cause is that session-scoped MCP state is scattered across 4 different objects (MCPManager._toolset_cache, Agent._session_connection_pool, Agent._mcp_snapshot, AcpMcpConnectionManager._connections) with no coordinated cleanup. This causes silent session failures on resume — the agentlet tries to initialize MCP via dead transports, causing a 300-second timeout.

What's in this PR

This PR contains only the spec (no implementation). It adds:

  • proposal.md — What & why: 6 lifecycle fixes, no config API changes
  • design.md — 8 design decisions (D1-D8) covering session tracking, toolset cache scoping, cleanup wiring, concurrency protection, WebSocket disconnect hook
  • specs/mcp-session-lifecycle/spec.md — New capability: 7 requirements, 14 scenarios
  • specs/session-orchestration/spec.md — Modified: close_session cleanup, agent registration
  • specs/unified-session-lifecycle/spec.md — Modified: WebSocket disconnect hook
  • tasks.md — 7 task groups, 46 tasks (P1a→P1f + E2E verification)
  • tests/mcp_server/test_stale_mcp_connection.py — 5 reproduction tests (all passing)

Design Decisions

ID Decision Key Trade-off
D1 Session tracking on MCPManager (not separate class) Fewer classes, centralized ownership
D2 Per-session toolset cache (not "no cache") Stale-free without per-turn overhead
D3 Global _toolset_cache for pool-level only Safe caching for long-lived resources
D4 as_capability(session_id) simplified API Reduces coupling
D5 Reverse index for AcpMcpConnectionManager Avoids invasive transport layer changes
D6 resume_session via SessionController.close_session() Handles active runs safely
D7 WebSocket disconnect via on_disconnect callback Decouples transport from session manager
D8 Per-session asyncio.Lock for concurrency safety Idempotent cleanup under concurrent calls

Review Status

Reviewed by Momus (PASS) and Oracle (PASS) after 2 revision cycles.

Revision history

  • Round 1: Oracle flagged 3 issues (D6 close path insufficient, D7 wiring underspecified, missing concurrency protection)
  • Round 2: All conditions addressed → both reviewers PASS

Migration Plan

This is Phase 1 of a 2-phase MCP lifecycle redesign:

  • Phase 1 (this spec): Fix lifecycle bugs without config API changes
  • Phase 2 (future): Remove per-agent MCP tier, move all servers to pool level with allow/block filtering

Related

Next Steps

Run /opsx:apply to begin implementation following tasks.md.

Add OpenSpec change for fixing stale MCP toolset cache and session-scoped
resource lifecycle bugs (#121). Includes:

- proposal.md: What & why (6 lifecycle fixes, no config changes)
- design.md: 8 design decisions (D1-D8) with Oracle + Momus review
- specs/mcp-session-lifecycle: 7 requirements, 14 scenarios
- specs/session-orchestration: Modified requirements for close path
- specs/unified-session-lifecycle: WebSocket disconnect hook
- tasks.md: 7 task groups, 46 tasks (P1a-P1f + E2E)
- tests/mcp_server/test_stale_mcp_connection.py: 5 reproduction tests

Reviewed by Momus (PASS) and Oracle (PASS) after 2 revision cycles.

Closes #121 (spec phase)

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a comprehensive redesign of the MCP-over-ACP session lifecycle to resolve resource leaks that occur during session resumption and WebSocket disconnects. The changes include implementing per-session MCP resource tracking in the MCPManager, adding a deterministic cleanup mechanism for session-scoped resources, and ensuring that all session closure paths—including resume and disconnect—properly handle active run cancellation before cleaning up resources. My review identified several critical design gaps regarding session key tracking in the AcpMcpConnectionManager, potential attribute errors in cleanup paths, and the need for more robust resource cleanup in both production code and test suites.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread openspec/changes/fix-mcp-session-lifecycle/tasks.md Outdated
Comment thread openspec/changes/fix-mcp-session-lifecycle/tasks.md Outdated
Comment thread openspec/changes/fix-mcp-session-lifecycle/tasks.md Outdated
Comment thread openspec/changes/fix-mcp-session-lifecycle/tasks.md Outdated
Comment thread openspec/changes/fix-mcp-session-lifecycle/tasks.md Outdated
Comment thread openspec/changes/fix-mcp-session-lifecycle/tasks.md Outdated
Comment thread tests/mcp_server/test_stale_mcp_connection.py Outdated
Leoyzen added 19 commits July 7, 2026 18:40
4 accepted fixes from dialectical analysis with Oracle:

1. Task 3.1/3.2/3.3: Change _session_connections to
   dict[str, set[tuple[str, int]]] — store (connection_id,
   session_key) pairs so AcpMcpConnectionManager.cleanup_session()
   can look up SessionStreamPair via session_key

2. Task 5.2 (D6): Two-layer cleanup on resume — call both
   SessionController.close_session() (RunHandle lifecycle) AND
   ACPSession.close() (ACP env/signals/prompts). Neither alone
   is sufficient.

3. Task 6.4 (D7): Same two-layer cleanup for WebSocket disconnect

4. Task 2.8: Use try/finally or fixture teardown for test cleanup

Rejected comments (2):
- hasattr(self.agent, 'mcp'): violates AGENTS.md, mcp always set
- hasattr(agent, 'mcp'): same, agent is not None check exists

Already addressed (2):
- Concurrency re-verify after lock: spec's lock-on-context design
  handles this implicitly
- await on_disconnect: type signature makes it obvious
- Add _SessionContext dataclass to MCPManager with per-session state
  (connection_pool, toolset_cache, snapshot, acp_connection_ids, _cleanup_lock)
- Add _session_contexts dict to MCPManager.__init__
- Add _session_connections reverse index to AcpMcpConnectionManager
- Add register_session_connection() method for tracking session→connection mappings

Implements T1 and T6 of fix-mcp-session-lifecycle plan.
- get_or_create_session() and update_session_snapshot() on MCPManager (T2)
- add_acp_transport() on MCPManager for session-scoped ACP tracking (T3)
- register_session() returns tuple[SessionStreamPair, int] (T7, GAP-1)
- has_active_sessions() on AcpMcpConnection (T7)
- cleanup_session() with _cleanup_lock on AcpMcpConnectionManager (T7, GAP-12)
- Updated all callers of register_session() to unpack tuple return
…nnection

- cleanup_session() with per-session _cleanup_lock on MCPManager (T4)
- _acp_mcp_manager field added for ACP cleanup delegation
- connect_acp_mcp_server() gains session_id parameter (T8, GAP-5)
- Returns tuple[str, int] (connection_id, session_key)
- Call site in session.py passes session_id and calls add_acp_transport
- All test callers updated for new signature
- Change as_capability(snapshot=, session_pool=) to as_capability(session_id=)
- Parameterize _make_capability with toolset_cache dict parameter (GAP-7)
- Split _process_snapshot into _process_global_configs and _process_session_configs
- GAP-11: KeyError fallback for concurrent cleanup_session race
- Backward compat: session_id=None processes self.servers with self._toolset_cache
… (T12)

- Replace as_capability(snapshot=, session_pool=) with as_capability(session_id=)
- GAP-4: Use run_ctx.session_id from AgentRunContext instead of self._session_id
- Remove if/else branching on _mcp_snapshot — as_capability handles internally
- Keep _mcp_snapshot and _session_connection_pool field declarations for compat
- Update 6 tests in test_mcpmanager_caching.py for new as_capability(session_id) API
- Update 15 failing tests in test_mcp_provider_lifecycle.py to use session context
- Fix static source assertion in test_no_dedup_hack_in_get_agentlet
- All 48 tests pass
- Rename test_session_resume_returns_stale_toolset → _returns_fresh_toolset
- Rename test_multiple_acp_servers_all_go_stale → _get_fresh_toolsets
- Rename test_disconnect_all_clears_cache → test_cleanup_session_clears_per_session_cache
- All 5 tests now verify the fix instead of documenting the bug
- All tests pass with new session_id API
- Add on_disconnect: Callable[[AgentSideConnection], Awaitable[None]] | None parameter
- Generate UUID4 connection_id on AgentSideConnection at accept time (GAP-3)
- Call on_disconnect in ConnectionClosed handler before conn.close()
- Backward compatible: on_disconnect defaults to None
- Add _connection_sessions reverse index on ACPSessionManager
- Add connection_id parameter to create_session() and resume_session()
- Implement close_all_sessions_for_connection() for WebSocket disconnect cleanup
- Idempotent: pops connection_id, iterates sessions, closes via SessionController + ACPSession.close()
- Add on_disconnect parameter to serve(), _serve_websocket(), _serve_streamable_http()
- Wire on_disconnect callback in ACPServer._start_async() closure
- Add session_manager field to AgentPoolACPAgent for shared session tracking
- Create shared ACPSessionManager in ACPServer for cross-connection session tracking
- Add disconnect detection in _serve_streamable_http via recv_task completion
- Fix test_resume_session_is_idempotent -> test_resume_session_closes_old_and_recreates
  (T20 changed resume_session from idempotent to close-then-recreate)
…(T27+T28)

- T27: test_websocket_disconnect_closes_all_sessions — 2 sessions same conn, disconnect, both closed
- T27: test_websocket_disconnect_preserves_other_connections — 2 conns, disconnect one, other survives
- T28: test_websocket_disconnect_during_run — active run cancelled with 2s timeout on disconnect
…ssion lifecycle test (T33)

- T32: Use cast() to type session_manager field as ACPSessionManager (not | None) for mypy
- T33: test_e2e_session_lifecycle — full lifecycle: connect→session→MCP→disconnect→reconnect→resume→verify fresh
@Leoyzen

Leoyzen commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

/gemini review

@gemini-code-assist

Copy link
Copy Markdown

Warning

Gemini encountered an error creating the review. You can try again by commenting /gemini review.

@Leoyzen

Leoyzen commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements Phase 1 of the MCP session lifecycle redesign, centralizing session-scoped state in _SessionContext on MCPManager, wiring cleanup into all close paths, and fixing resume_session() to close-then-recreate. The code review highlights several critical and high-severity issues, including a potential premature termination bug in resume_session() due to untracked session mappings in _connection_sessions, missing imports (asyncio and uuid), and potential memory leaks or unnecessary instantiations in as_capability() and cleanup_session(). Additionally, the reviewer suggests adding defensive None checks for agent.mcp and validating connection identifiers before registration.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread src/agentpool_server/acp_server/session_manager.py
Comment thread src/agentpool/mcp_server/manager.py
Comment thread src/agentpool/mcp_server/manager.py Outdated
Comment thread src/acp/transports.py
Comment thread src/agentpool/mcp_server/manager.py Outdated
Comment thread src/agentpool/mcp_server/manager.py
Comment thread src/agentpool_server/acp_server/acp_mcp_manager.py
Comment thread src/agentpool/orchestrator/session_controller.py
Comment thread src/agentpool_server/acp_server/session.py
Leoyzen added 2 commits July 7, 2026 21:32
- ruff format: reformat 5 files (manager.py, session_controller.py, session.py, test_session_lifecycle.py, test_stale_mcp_connection.py)
- ruff check: shorten docstring in test_acp_session_resume.py (E501)
…e leaks

- Fix #1 (Critical): resume_session() now removes session_id from
  _connection_sessions before closing old session. Prevents stale
  connection disconnect from closing the newly resumed session.
- Fix #2 (High): as_capability() uses _session_contexts.get() instead of
  get_or_create_session(). Prevents memory leak when context was already
  cleaned up. Removes dead try/except KeyError code.
- Fix #3 (Medium): cleanup_session() uses _session_contexts.get() and
  returns early if None. Avoids creating throwaway SessionConnectionPool.
- TDD: 3 tests in test_review_fixes.py verify all fixes.
@Leoyzen

Leoyzen commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

/gemini review

@Leoyzen
Leoyzen requested a review from Million-mo July 7, 2026 13:48
@gemini-code-assist

Copy link
Copy Markdown

Warning

Gemini encountered an error creating the review. You can try again by commenting /gemini review.

@Leoyzen

Leoyzen commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements Phase 1 of the MCP session lifecycle redesign, centralizing session-scoped MCP state into _SessionContext on MCPManager and ensuring proper cleanup of resources (toolsets, transports, and ACP connections) when sessions close or WebSocket connections drop. The feedback identifies a critical issue where _acp_mcp_manager is never set in production, causing session-scoped ACP connections to leak, and a missing uuid import in transports.py that will cause a runtime NameError. Additionally, suggestions are provided to enhance idempotency in cleanup_session and reduce duplicated fallback logic in as_capability.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread src/agentpool_server/acp_server/session.py
Comment thread src/acp/transports.py
Comment thread src/agentpool/mcp_server/manager.py
Comment thread src/agentpool/mcp_server/manager.py Outdated
Leoyzen added 3 commits July 7, 2026 22:32
…apability

Review round 2 fixes:

Fix #4 (Critical): Wire _acp_mcp_manager in ACPSession.__post_init__
- MCPManager._acp_mcp_manager was initialized to None and never set
- cleanup_session() could never delegate to AcpMcpConnectionManager
- Per-session ACP stream pairs and reverse-index entries leaked
- Fix: wire agent.mcp._acp_mcp_manager = acp_agent._mcp_manager in __post_init__

Fix #5 (Medium): Identity check after acquiring cleanup lock
- Concurrent cleanup_session() callers could do redundant work
- All ops were idempotent but wasteful (clearing empty dicts, etc.)
- Fix: check if self._session_contexts.get(session_id) is not ctx after lock

Fix #6 (Medium): Consolidate duplicated fallback in as_capability()
- Three identical 'for server in self.servers:' loops consolidated to one
- Pure readability refactor, zero behavior change

TDD: 3 new tests (2 RED before fix, 3 GREEN after)
- test_cleanup_session_delegates_to_acp_mcp_manager (unit)
- test_acp_session_wires_acp_mcp_manager (integration)
- test_cleanup_session_identity_check_prevents_redundant_work (unit)

213 tests pass, ruff clean.
Categories A-D from Oracle integration test plan:
- A (4): Cross-component wiring — cleanup delegation, __post_init__ wiring,
  full close chain, close_all_sessions_for_connection
- B (7): Lifecycle edge cases — full create/cleanup, close/recreate,
  shared connection isolation, WebSocket disconnect, resume, concurrent cleanup
- C (4): State consistency — registry consistency after cleanup/close/resume,
  stream pair unregistration
- D (5): Error paths — ACP manager raises, session close raises,
  MCP cleanup raises, resume old close raises, pool cleanup raises

These tests would have caught the _acp_mcp_manager wiring bug (round 2
review comment #1) that unit tests missed due to component isolation.
…ll sites

- Declare connection_id: str | None on AgentSideConnection (replaces monkey-patch)
- Remove # type: ignore[attr-defined] from transports.py connection_id assignments
- Add _get_connection_id() helper on AgentPoolACPAgent using isinstance check
- Wire connection_id= into all 5 create_session/resume_session call sites:
  new_session, load_session, fork_session, resume_session, handler.py
- Fix misleading GAP-11 comment: dict.get() returns None, never raises KeyError

Without this fix, _connection_sessions dict was never populated, making
close_all_sessions_for_connection() always return immediately — the entire
WebSocket disconnect cleanup feature was dead code.
@Leoyzen

Leoyzen commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

/gemini review

@gemini-code-assist

Copy link
Copy Markdown

Warning

Gemini encountered an error creating the review. You can try again by commenting /gemini review.

Covers all 13 gap areas identified by Oracle analysis:
- G1: Full create_session → get_or_create_session_agent → MCPManager chain
- G2: as_capability with non-empty ACP snapshot → real MCPToolset
- G3: initialize_mcp_servers → connect_acp_mcp_server → AcpMcpTransport
- G4: Full tool execution through as_capability → MCPToolset → AcpMcpTransport
- G5: SessionController.close_session with real agent + real MCP resources
- G6: resume_session with real ACPSession (not patched)
- G7: Full on_disconnect → close_all_sessions_for_connection chain
- G8: connection_id propagation: create_session populates _connection_sessions
- G9: as_capability during concurrent cleanup (GAP-11 race)
- G10: ACP transport failure during tool execution + cleanup
- G11: Multiple sessions on same connection with real ACPSessions
- G12: Child session inherits parent's ACP transports
- G13: Pool shutdown cleans all session MCP resources
@Leoyzen

Leoyzen commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

/gemini review

- server.py: Remove unused type: ignore, use None guard for connection_id
- test_acp_session_resume.py: Add connection_id to expected resume_session call args
@gemini-code-assist

Copy link
Copy Markdown

Warning

Gemini encountered an error creating the review. You can try again by commenting /gemini review.

- Mark all 46 tasks as complete in tasks.md
- Sync 3 delta specs to main specs:
  - mcp-session-lifecycle (new)
  - session-orchestration (updated)
  - unified-session-lifecycle (updated)
- Archive to openspec/changes/archive/2026-07-07-fix-mcp-session-lifecycle/
@Leoyzen

Leoyzen commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements a robust cleanup mechanism for MCP session-scoped resources, including toolsets, connection pools, and ACP connections, to prevent resource leaks. Key changes include centralizing session state in _SessionContext, wiring cleanup into session close and WebSocket disconnect paths, and refining session management logic. Feedback from the review addressed potential runtime errors (missing imports, AttributeError risks) and memory leaks (unnecessary context recreation), which have been incorporated to ensure the lifecycle management is both safe and efficient.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread src/acp/transports.py
Comment thread src/acp/transports.py Outdated
Comment thread src/agentpool/orchestrator/session_controller.py Outdated
Comment thread src/agentpool/orchestrator/session_controller.py Outdated
Comment thread src/agentpool_server/acp_server/session.py
- session_controller.py: Replace get_or_create_session() with
  _session_contexts.get() when reading parent snapshot/pool. Prevents
  phantom _SessionContext creation when parent was already cleaned up.
- transports.py: Move on_disconnect callback from except ConnectionClosed
  to finally block. Ensures callback fires on any exception path.
- 3 TDD tests: leak detection, regression guard, disconnect coverage.
@Leoyzen

Leoyzen commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements Phase 1 of the MCP session lifecycle redesign, resolving resource leaks by centralizing session-scoped state into _SessionContext on MCPManager and ensuring coordinated cleanup across all session close and WebSocket disconnect paths. It also updates as_capability() to use a session-isolated toolset cache and replaces the early-return in resume_session() with a close-then-recreate flow. The review feedback correctly identifies a potential AttributeError in SessionController._close_session_run_turn when accessing agent.mcp on non-native or mock agents, suggesting an explicit type check to ensure safety.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread src/agentpool/orchestrator/session_controller.py
@Million-mo

Copy link
Copy Markdown
Collaborator

Architecture Review: MCP Lifecycle Analysis

Reviewed the full PR from a lifecycle architecture perspective. The core design is sound — three-tier model (Pool → Agent → Session), _SessionContext centralization, per-session toolset isolation, and the cleanup delegation chain are all correct. Below are the structural gaps I found.


✅ What's Correct

  1. Three-tier lifecycle model — each tier maps to a clear host object, resource lifecycle = host lifecycle.
  2. _SessionContext centralization — consolidates 4 previously scattered objects into one dataclass.
  3. Per-session toolset cache isolation — global configs use self._toolset_cache, session configs use ctx.toolset_cache. Correct: global transport is shared → shared cache; session transport is isolated → isolated cache.
  4. McpConfigSnapshot immutable patternfrozen=True + with_*_configs() returning new instances. Standard immutable data pattern, avoids concurrent modification.
  5. Cleanup chainMCPManager.cleanup_session()AcpMcpConnectionManager.cleanup_session() delegation with per-session lock + identity check. Safe under concurrency.

❌ Structural Gaps

1. Subagent inherits ACP transport without registering on connection manager (High)

This is the most significant structural gap. Child sessions copy ACP transport references via copy_pre_created_transports(), but do not register in AcpMcpConnectionManager._session_connections or _SessionContext.acp_connection_ids.

Failure chain:

child.copy_pre_created_transports(parent)
  → child's SessionConnectionPool has transport reference
  → but _session_connections has NO child entry
  → parent session closes → cleanup_session(parent_id)
    → unregister parent's stream pair
    → conn.has_active_sessions() == False (child never registered)
    → remove_connection() → conn.close()
  → child's transport now points to closed AcpMcpConnection
  → child's next MCP tool call → silent failure or timeout

Deeper issue: copy_pre_created_transports copies the transport object reference, not a new stream pair. ACP multiplexing requires each session to have its own SessionStreamPair. Child session using parent's stream pair will cause message cross-talk. Correct approach: child should call conn.register_session() to get its own stream pair, then create its own AcpMcpTransport.

In session_controller.py:

# Current — only copies transport reference
await child_ctx.connection_pool.copy_pre_created_transports(
    parent_ctx.connection_pool
)
# Missing — register child on ACP connection manager
for (conn_id, session_key) in parent_ctx.acp_connection_ids:
    conn = parent_agent.mcp._acp_mcp_manager._connections.get(conn_id)
    if conn is not None:
        _pair, child_session_key = conn.register_session()
        parent_agent.mcp._acp_mcp_manager.register_session_connection(
            child_session_id, conn_id, child_session_key
        )
        await child_agent.mcp.add_acp_transport(
            child_session_id, client_id, transport, conn_id, child_session_key
        )

2. GlobalConnectionPool uses threading.Lock blocking event loop (Medium-High)

# src/agentpool/mcp_server/global_pool.py:103
self._lock = threading.Lock()

The code even has a comment saying it should switch:

# work is added inside the lock, switch to asyncio.Lock to avoid blocking
# the event loop.

threading.Lock in async context blocks the entire event loop thread. If a stdio owner task is slow to start (MCP server initialization takes seconds), all other coroutines are blocked. Should be asyncio.Lock.

3. Global _toolset_cache has no invalidation on transport death (Medium)

When a stdio owner task crashes, GlobalConnectionPool._run_session() removes the connection from _connections in its finally block. But MCPManager._toolset_cache still holds a MCPToolset referencing the dead transport.

Next as_capability() call: _make_capability() finds the cached toolset and reuses it — never tries to create a new transport. Result: after stdio MCP server crash, all subsequent sessions silently use a dead toolset until pool restart.

Fix options:

  • GlobalConnectionPool notifies MCPManager to invalidate the corresponding cache entry on connection removal, OR
  • _make_capability() does a lightweight health check before reusing cached toolset.

4. agent._mcp_snapshot updates have no concurrency protection (Medium)

initialize_mcp_servers() creates new snapshot via with_session_configs() then assigns:

self.agent._mcp_snapshot = new_snapshot

get_agentlet() also reads/writes via with_skill_configs(). If these run concurrently (e.g., session initialization while skill loading triggers), last writer wins, other's changes silently lost.

McpConfigSnapshot is frozen=True (immutable), but the field replacement is not atomic. Needs an asyncio.Lock for snapshot read/write, or fully migrate snapshot to _SessionContext (PR #122 partially did this, but agent._mcp_snapshot still exists).

5. Agent-level MCPManager architectural redundancy (Low — Phase 2)

Agent can have its own MCPManager (_mcp_shared=False) or share pool's. Issues:

  • Agent-level MCPManager has its own _global_pool — if pool-level and agent-level declare the same stdio server, two independent processes start
  • _acp_mcp_manager starts as None, only wired in ACPSession.__post_init__ — if agent used outside ACP context, field stays None forever
  • _mcp_shared binary state complicates _build_agent_configs() logic

Phase 2 direction (all servers at pool level, agent keeps allow/block filter only) is correct and will eliminate this entire layer of complexity.

6. No transport health check for resume_session (Low)

resume_session now unconditionally close-then-recreates. If MCP transports are still healthy (stdio process alive, ACP connection intact), this rebuild is pure waste.

Could add lightweight health check: stdio → check owner task alive; ACP → check WebSocket connected. Only unhealthy transports get rebuilt. Would reduce resume latency from "full init" to "quick check + conditional rebuild".


Summary

Item Status Priority
Three-tier lifecycle model ✅ Correct
_SessionContext centralization ✅ Correct
Per-session toolset isolation ✅ Correct
Cleanup chain structure ✅ Correct
Subagent ACP transport registration ❌ Missing High
GlobalConnectionPool lock type ⚠️ Commented but unfixed Medium-High
Global toolset invalidation ❌ Missing Medium
Snapshot concurrency protection ⚠️ No lock Medium
Agent-level MCPManager redundancy ⚠️ Phase 2 Low
Transport health check ❌ Missing Low

#1 (subagent ACP transport registration) is the only item that could cause silent data corruption in production. Recommend addressing it immediately after this PR merges. The rest can be tackled systematically in Phase 2.

… toolset __aexit__

Three fixes for child session ACP transport registration gaps:

1. Wire _acp_mcp_manager on child agent from parent (session_controller.py)
   - Child sessions created via get_or_create_session_agent() don't go
     through ACPSession.__post_init__, so _acp_mcp_manager stayed None.
     Now copied from parent after copy_pre_created_transports().

2. Add on_session_registered callback to AcpMcpTransport (acp_mcp_transport.py)
   - Optional callback invoked after register_session() with (connection_id,
     session_key). Enables callers to register ACP connections for cleanup
     tracking via register_session_connection().

3. Fix toolset_cache.clear() to call __aexit__ first (manager.py)
   - cleanup_session() called .clear() without closing MCPToolset instances,
     leaking stream pairs and forwarder tasks. Now mirrors disconnect_all()
     pattern: iterate values, call __aexit__(None, None, None) with
     contextlib.suppress(ValueError), then clear.

TDD: 3 tests in test_child_session_acp_fix.py (all GREEN).
252 MCP+ACP tests pass, 0 regressions, ruff clean.
@Leoyzen

Leoyzen commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

感谢这份详细的架构评审!逐条回复:


Gap 1: Subagent inherits ACP transport without registering on connection manager (High)

已在 commit b6cafcd6a 中修复。 实施了 3 项改动:

  1. Wire _acp_mcp_manager on child agentsession_controller.py 中,copy_pre_created_transports() 后从父 agent 继承 _acp_mcp_manager(子会话不走 ACPSession.__post_init__(),此前该字段一直是 None)。

  2. Add on_session_registered callback to AcpMcpTransport — 新增可选 Callable[[str, int], None] 参数。在 connect_session() 调用 register_session() 后触发回调,使调用方可以在懒加载时注册 ACP 连接到 _session_connectionsacp_connection_ids。每个子会话通过 connect_session() 获取独立的 SessionStreamPair,不存在消息串扰问题。

  3. Fix toolset_cache.clear() to call __aexit__() firstcleanup_session() 中先 await toolset.__aexit__(None, None, None) 再 clear,确保 stream pair 和 forwarder task 正确释放(此前对所有会话都有此问题,不仅仅是子会话)。

关于评审中提到的"message cross-talk"风险AcpMcpTransport.connect_session() 每次调用 register_session() 都会创建全新的 SessionStreamPair,子会话不会复用父会话的 stream pair。评审中的这一判断有误。

关于"parent cleanup kills child transport":子的 connect_session()AcpMcpConnection._session_streams 中注册了新的 entry,父 cleanup 只 unregister 父自己的 pairs(通过 _session_connections[parent_id] 追踪)。子 cleanup 后 has_active_sessions() 仍为 True,连接不会被关闭。评审中的这一 failure chain 也有误。


Gap 2: GlobalConnectionPool uses threading.Lock (Medium-High)

确认是 pre-existing issue。 代码中已有注释标注需要切换到 asyncio.Lock。此 PR 未涉及 GlobalConnectionPool 的改动,但认同应尽快修复。建议在后续 PR 中处理,因为切换 lock 类型需要审计所有调用路径确保不会引入死锁。


Gap 3: Global _toolset_cache has no invalidation on transport death (Medium)

Pre-existing issue,此 PR 已部分缓解。 PR 引入的 session-scoped ctx.toolset_cache 意味着每次新会话都会从空 cache 开始,不会继承全局 cache 中的死 toolset。全局 cache 的 invalidation 仍需处理(如 GlobalConnectionPool 通知 MCPManager 在连接移除时失效对应 cache entry),建议后续 PR。


Gap 4: agent._mcp_snapshot concurrency (Medium)

Pre-existing issue,此 PR 已改善。 PR 将 snapshot 存储从 agent._mcp_snapshot 字段迁移到 _SessionContext.snapshot(通过 get_or_create_session() + update_session_snapshot()),减少了直接字段写入的并发面。agent._mcp_snapshot 字段保留仅为 backward compat。完整的并发保护(snapshot read/write lock 或完全迁移到 _SessionContext)可在 Phase 2 处理。


Gap 5: Agent-level MCPManager redundancy (Low — Phase 2)

认同。Phase 2 将所有 MCP servers 移到 pool level,agent 仅保留 allow/block filter。当前架构的 _mcp_shared 二元状态和 agent-level MCPManager 是历史遗留,此 PR 未改变这一架构但也没有恶化它。


Gap 6: No transport health check for resume_session (Low)

Intentional design choice(safe over fast)。 resume_session 采用 close-then-recreate 策略而非 health-check-then-conditional-rebuild,原因是在 WebSocket 断连场景下无法可靠判断 ACP transport 是否仍然健康(WebSocket 可能处于 half-open 状态)。重建成本可接受(通常 < 1s),而错误判断健康状态会导致 silent failure。后续可加 lightweight health check 作为优化,但不应改变 close-then-recreate 的默认行为。


Summary

Gap Status Commit
1. Subagent ACP transport registration ✅ Fixed b6cafcd6a
2. GlobalConnectionPool lock type ⚠️ Pre-existing, follow-up
3. Global toolset_cache invalidation ⚠️ Pre-existing, partially mitigated
4. Snapshot concurrency ⚠️ Pre-existing, improved
5. Agent-level MCPManager redundancy ⚠️ Phase 2
6. Transport health check ℹ️ Intentional design

感谢审查!Gap 1 是唯一可能导致 silent data corruption 的问题,已在本 PR 中修复。其余均为 pre-existing 或 Phase 2 范畴。

@Million-mo Million-mo left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

approve

@Leoyzen
Leoyzen merged commit 610564e into develop/agentic Jul 8, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Stale MCP toolset in shared _toolset_cache causes silent session failure on resume

2 participants