mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-24 05:16:09 +00:00
fix(mcp): keep waiting for box recovery
This commit is contained in:
@@ -313,10 +313,9 @@ class RuntimeMCPSession:
|
|||||||
# that is reconnecting is handled above and waits for availability.
|
# that is reconnecting is handled above and waits for availability.
|
||||||
#
|
#
|
||||||
# Set ``error_phase = BOX_UNAVAILABLE`` BEFORE raising so the retry
|
# Set ``error_phase = BOX_UNAVAILABLE`` BEFORE raising so the retry
|
||||||
# wrapper can short-circuit (retrying is pointless when Box is
|
# wrapper can distinguish a deliberately disabled Box from an enabled
|
||||||
# deliberately off) and the frontend can render a localized,
|
# runtime that is still reconnecting. Keep the message itself short —
|
||||||
# actionable message instead of this raw RuntimeError. Keep the
|
# the frontend ignores it for this phase.
|
||||||
# message itself short — the frontend ignores it for this phase.
|
|
||||||
box_service = getattr(self.ap, 'box_service', None)
|
box_service = getattr(self.ap, 'box_service', None)
|
||||||
if box_service is not None and not getattr(box_service, 'available', False):
|
if box_service is not None and not getattr(box_service, 'available', False):
|
||||||
self.error_phase = MCPSessionErrorPhase.BOX_UNAVAILABLE
|
self.error_phase = MCPSessionErrorPhase.BOX_UNAVAILABLE
|
||||||
@@ -620,17 +619,29 @@ class RuntimeMCPSession:
|
|||||||
await asyncio.sleep(2)
|
await asyncio.sleep(2)
|
||||||
continue
|
continue
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.retry_count = attempt + 1
|
|
||||||
if self._shutdown_event.is_set():
|
if self._shutdown_event.is_set():
|
||||||
return # Shutdown requested, don't retry
|
return # Shutdown requested, don't retry
|
||||||
# BOX_UNAVAILABLE is a deliberate refusal, not a transient
|
|
||||||
# failure — retrying produces log spam and a misleading
|
|
||||||
# "Failed after N attempts" message. Surface it immediately.
|
|
||||||
if self.error_phase == MCPSessionErrorPhase.BOX_UNAVAILABLE:
|
if self.error_phase == MCPSessionErrorPhase.BOX_UNAVAILABLE:
|
||||||
|
box_service = getattr(self.ap, 'box_service', None)
|
||||||
|
if box_service is not None and getattr(box_service, 'enabled', True):
|
||||||
|
# Box is configured and may recover independently of
|
||||||
|
# this MCP session. Keep retrying without consuming the
|
||||||
|
# fatal budget; _wait_for_box_runtime() rate-limits the
|
||||||
|
# loop to one warning per startup timeout.
|
||||||
|
self.status = MCPSessionStatus.CONNECTING
|
||||||
|
self.error_message = None
|
||||||
|
self.error_phase = None
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
continue
|
||||||
|
# Explicitly disabled Box is a deliberate refusal, not a
|
||||||
|
# transient failure. Surface it immediately without log
|
||||||
|
# spam or a misleading "Failed after N attempts" message.
|
||||||
|
self.retry_count = attempt + 1
|
||||||
self.status = MCPSessionStatus.ERROR
|
self.status = MCPSessionStatus.ERROR
|
||||||
self.error_message = str(e)
|
self.error_message = str(e)
|
||||||
self._ready_event.set()
|
self._ready_event.set()
|
||||||
return
|
return
|
||||||
|
self.retry_count = attempt + 1
|
||||||
if attempt >= self._MAX_RETRIES:
|
if attempt >= self._MAX_RETRIES:
|
||||||
self.status = MCPSessionStatus.ERROR
|
self.status = MCPSessionStatus.ERROR
|
||||||
self.error_message = f'Failed after {self._MAX_RETRIES + 1} attempts: {self._describe_exception(e)}'
|
self.error_message = f'Failed after {self._MAX_RETRIES + 1} attempts: {self._describe_exception(e)}'
|
||||||
|
|||||||
@@ -793,6 +793,73 @@ class TestGetRuntimeInfoDict:
|
|||||||
|
|
||||||
assert ap.box_service.available is True
|
assert ap.box_service.available is True
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_enabled_box_timeout_does_not_exhaust_mcp_retry_budget(self, mcp_module, monkeypatch):
|
||||||
|
ap = _make_ap()
|
||||||
|
ap.box_service.available = False
|
||||||
|
ap.box_service.enabled = True
|
||||||
|
session = _make_session(
|
||||||
|
mcp_module,
|
||||||
|
{
|
||||||
|
'name': 'test',
|
||||||
|
'uuid': 'test-uuid',
|
||||||
|
'mode': 'stdio',
|
||||||
|
'command': 'python',
|
||||||
|
'args': [],
|
||||||
|
},
|
||||||
|
ap=ap,
|
||||||
|
)
|
||||||
|
attempts = 0
|
||||||
|
|
||||||
|
async def lifecycle():
|
||||||
|
nonlocal attempts
|
||||||
|
attempts += 1
|
||||||
|
if attempts == 1:
|
||||||
|
session.error_phase = mcp_module.MCPSessionErrorPhase.BOX_UNAVAILABLE
|
||||||
|
raise RuntimeError('Box runtime is not available after 1 seconds')
|
||||||
|
|
||||||
|
session._lifecycle_loop = lifecycle
|
||||||
|
sleep = AsyncMock()
|
||||||
|
monkeypatch.setattr(mcp_module.asyncio, 'sleep', sleep)
|
||||||
|
|
||||||
|
await session._lifecycle_loop_with_retry()
|
||||||
|
|
||||||
|
assert attempts == 2
|
||||||
|
assert session.retry_count == 0
|
||||||
|
sleep.assert_awaited_once_with(1)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_disabled_box_still_stops_mcp_retry_loop(self, mcp_module):
|
||||||
|
ap = _make_ap()
|
||||||
|
ap.box_service.available = False
|
||||||
|
ap.box_service.enabled = False
|
||||||
|
session = _make_session(
|
||||||
|
mcp_module,
|
||||||
|
{
|
||||||
|
'name': 'test',
|
||||||
|
'uuid': 'test-uuid',
|
||||||
|
'mode': 'stdio',
|
||||||
|
'command': 'python',
|
||||||
|
'args': [],
|
||||||
|
},
|
||||||
|
ap=ap,
|
||||||
|
)
|
||||||
|
attempts = 0
|
||||||
|
|
||||||
|
async def lifecycle():
|
||||||
|
nonlocal attempts
|
||||||
|
attempts += 1
|
||||||
|
session.error_phase = mcp_module.MCPSessionErrorPhase.BOX_UNAVAILABLE
|
||||||
|
raise RuntimeError('box_disabled_in_config')
|
||||||
|
|
||||||
|
session._lifecycle_loop = lifecycle
|
||||||
|
|
||||||
|
await session._lifecycle_loop_with_retry()
|
||||||
|
|
||||||
|
assert attempts == 1
|
||||||
|
assert session.status == mcp_module.MCPSessionStatus.ERROR
|
||||||
|
assert session.retry_count == 1
|
||||||
|
|
||||||
def test_stdio_session_without_box_service_uses_local_stdio(self, mcp_module):
|
def test_stdio_session_without_box_service_uses_local_stdio(self, mcp_module):
|
||||||
ap = _make_ap()
|
ap = _make_ap()
|
||||||
del ap.box_service
|
del ap.box_service
|
||||||
|
|||||||
Reference in New Issue
Block a user