Actual Behavior
uv run pytest tests/agent_server/stress/test_concurrent_conversations.py reproduces the concurrency regression.
ConversationService guards all conversation lifecycle operations with a single global asyncio.Lock (_lifecycle_lock, conversation_service.py:644). It is acquired in 9 places across the service, and two of those perform slow/blocking work under the lock:
| Line |
Method |
Blocking work under the lock |
~1064 |
_get_or_load_event_service_locked |
asyncio.to_thread(_prepare_persisted_runtime) — disk I/O + runtime prep |
~1697 |
delete_conversation |
await event_service.close() — can hang indefinitely (see #4546) |
Because every conversation operation (create, open, search, delete, event-stream subscribe) acquires the same lock, a stuck or slow operation on conversation A blocks every other conversation — the entire server wedges while /health and metadata routes keep answering, making it look healthy when it isn't.
Why PRs #4513 and #4548 are mitigations, not fixes
Both fix symptoms (one read-path deadlock, one unbounded hang) without addressing the structural issue: a single coarse-grained lock for the entire conversation lifecycle.
Expected Behavior
A stuck or slow operation on one conversation must not block operations on any other conversation. The conversation-lifecycle lock should be per-conversation, not global.
Proposed Solution
Replace the single _lifecycle_lock with per-conversation locks, plus a tiny lock for catalog (dict) mutation only:
# Before (current):
_lifecycle_lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False)
# After (proposed):
_conversation_locks: dict[UUID, asyncio.Lock] = field(default_factory=dict, init=False)
_catalog_lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False) # protects only dict mutation
def _get_conversation_lock(self, conversation_id: UUID) -> asyncio.Lock:
with self._catalog_lock: # brief, only protects the dict itself
if conversation_id not in self._conversation_locks:
self._conversation_locks[conversation_id] = asyncio.Lock()
return self._conversation_locks[conversation_id]
Each method acquires only its conversation's lock:
- A stuck
close() on conversation A blocks only conversation A
- Conversation B's create/search/open proceeds unimpeded
- The catalog dict (
_event_services, _conversation_records) gets its own tiny lock for mutation only
The SDK already has the right pattern: ResourceLockManager (resource_lock_manager.py) does per-resource FIFO locks for tools. The same approach should be applied at the conversation-lifecycle layer.
Acceptance Criteria
Mitigations (in-flight)
Once this issue is implemented, those PRs can likely be closed as superseded.
This issue body was updated by an AI agent (OpenHands) on behalf of the user to satisfy the repository readiness check.
Actual Behavior
uv run pytest tests/agent_server/stress/test_concurrent_conversations.pyreproduces the concurrency regression.ConversationServiceguards all conversation lifecycle operations with a single globalasyncio.Lock(_lifecycle_lock,conversation_service.py:644). It is acquired in 9 places across the service, and two of those perform slow/blocking work under the lock:~1064_get_or_load_event_service_lockedasyncio.to_thread(_prepare_persisted_runtime)— disk I/O + runtime prep~1697delete_conversationawait event_service.close()— can hang indefinitely (see #4546)Because every conversation operation (create, open, search, delete, event-stream subscribe) acquires the same lock, a stuck or slow operation on conversation A blocks every other conversation — the entire server wedges while
/healthand metadata routes keep answering, making it look healthy when it isn't.Why PRs #4513 and #4548 are mitigations, not fixes
_event_servicescache. New conversation creation (line ~1332) and delete/close (line ~1697) still take the global lock and can still hang.close()) replaces "hang forever" with "block all conversations for 30s". That's a shorter wedge, but still a wedge — and serial 30s stalls accumulate under load.Both fix symptoms (one read-path deadlock, one unbounded hang) without addressing the structural issue: a single coarse-grained lock for the entire conversation lifecycle.
Expected Behavior
A stuck or slow operation on one conversation must not block operations on any other conversation. The conversation-lifecycle lock should be per-conversation, not global.
Proposed Solution
Replace the single
_lifecycle_lockwith per-conversation locks, plus a tiny lock for catalog (dict) mutation only:Each method acquires only its conversation's lock:
close()on conversation A blocks only conversation A_event_services,_conversation_records) gets its own tiny lock for mutation onlyThe SDK already has the right pattern:
ResourceLockManager(resource_lock_manager.py) does per-resource FIFO locks for tools. The same approach should be applied at the conversation-lifecycle layer.Acceptance Criteria
ConversationServiceuses per-conversation locks instead of a single global_lifecycle_lockclose()/_prepare_persisted_runtimeon conversation A does not blockget_event_service/create_conversation/delete_conversationfor conversation Bconversation_serviceandevent_servicetests passMitigations (in-flight)
AsyncExecutor.close()timeout (shortens the wedge but doesn't eliminate cross-conversation blocking)Once this issue is implemented, those PRs can likely be closed as superseded.
This issue body was updated by an AI agent (OpenHands) on behalf of the user to satisfy the repository readiness check.