mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-24 05:16:09 +00:00
fix(runtime): complete reconnect recovery paths
This commit is contained in:
@@ -126,6 +126,29 @@ async def test_box_runtime_connector_cleans_partial_transport_on_connect_failure
|
||||
connector._close_managed_subprocess.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_box_runtime_connector_starts_heartbeat_after_reconnect(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
monkeypatch.setattr('langbot.pkg.utils.platform.get_platform', lambda: 'linux')
|
||||
monkeypatch.setattr('langbot.pkg.utils.platform.standalone_box', False)
|
||||
connector = BoxRuntimeConnector(make_app(Mock()))
|
||||
connector._start_local_stdio = AsyncMock(side_effect=[RuntimeError('bind failed'), None])
|
||||
connector._stop_transport = AsyncMock()
|
||||
connector._close_managed_subprocess = AsyncMock()
|
||||
|
||||
with pytest.raises(RuntimeError, match='bind failed'):
|
||||
await connector.initialize()
|
||||
|
||||
assert connector._heartbeat_task is None
|
||||
|
||||
await connector.reconnect()
|
||||
|
||||
assert connector._heartbeat_task is not None
|
||||
assert not connector._heartbeat_task.done()
|
||||
await connector.aclose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_box_stdio_connection_does_not_capture_unconsumed_stderr(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
||||
@@ -347,6 +347,28 @@ async def test_box_service_shutdown_reaps_connector_when_runtime_rpc_is_offline(
|
||||
connector.aclose.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_box_service_reconnect_restores_workspace_and_runs_cleanup(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
app = make_app(Mock())
|
||||
app.skill_mgr = SimpleNamespace(reload_skills=AsyncMock())
|
||||
service = BoxService(app, client=Mock(spec=BoxRuntimeClient))
|
||||
connector = Mock()
|
||||
connector.reconnect = AsyncMock()
|
||||
service._ensure_default_workspace = Mock()
|
||||
service._purge_attachment_dirs = AsyncMock()
|
||||
monkeypatch.setattr('langbot.pkg.box.service.asyncio.sleep', AsyncMock())
|
||||
|
||||
await service._reconnect_loop(connector)
|
||||
|
||||
connector.reconnect.assert_awaited_once()
|
||||
service._ensure_default_workspace.assert_called_once()
|
||||
service._purge_attachment_dirs.assert_awaited_once()
|
||||
app.skill_mgr.reload_skills.assert_awaited_once()
|
||||
assert service.available is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_box_runtime_reuses_request_session():
|
||||
logger = Mock()
|
||||
|
||||
@@ -725,14 +725,10 @@ class TestGetRuntimeInfoDict:
|
||||
)
|
||||
assert writable._build_box_session_id() != default._build_box_session_id()
|
||||
|
||||
def test_stdio_session_refuses_when_box_unavailable(self, mcp_module):
|
||||
"""Policy: when Box is configured but unavailable (disabled in config
|
||||
OR connection failed), stdio MCP servers are NOT treated as box-stdio.
|
||||
``_init_stdio_python_server`` will raise a clear refusal at start
|
||||
time; until then, the runtime info simply omits box_session_id so the
|
||||
UI can render the disabled state cleanly."""
|
||||
def test_stdio_session_waits_for_enabled_box_when_temporarily_unavailable(self, mcp_module):
|
||||
ap = _make_ap()
|
||||
ap.box_service.available = False
|
||||
ap.box_service.enabled = True
|
||||
s = _make_session(
|
||||
mcp_module,
|
||||
{
|
||||
@@ -745,9 +741,58 @@ class TestGetRuntimeInfoDict:
|
||||
ap=ap,
|
||||
)
|
||||
info = s.get_runtime_info_dict()
|
||||
assert info['box_session_id'] == 'mcp-shared'
|
||||
assert info['box_enabled'] is True
|
||||
|
||||
def test_stdio_session_refuses_when_box_is_disabled(self, mcp_module):
|
||||
ap = _make_ap()
|
||||
ap.box_service.available = False
|
||||
ap.box_service.enabled = False
|
||||
s = _make_session(
|
||||
mcp_module,
|
||||
{
|
||||
'name': 'test',
|
||||
'uuid': 'test-uuid',
|
||||
'mode': 'stdio',
|
||||
'command': 'python',
|
||||
'args': [],
|
||||
},
|
||||
ap=ap,
|
||||
)
|
||||
|
||||
info = s.get_runtime_info_dict()
|
||||
|
||||
assert 'box_session_id' not in info
|
||||
assert 'box_enabled' not in info
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stdio_session_waits_until_box_reconnects(self, mcp_module, monkeypatch):
|
||||
mcp_stdio_module = sys.modules['langbot.pkg.provider.tools.loaders.mcp_stdio']
|
||||
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': [],
|
||||
'box': {'startup_timeout_sec': 1},
|
||||
},
|
||||
ap=ap,
|
||||
)
|
||||
|
||||
async def reconnect_box(_delay):
|
||||
ap.box_service.available = True
|
||||
|
||||
monkeypatch.setattr(mcp_stdio_module.asyncio, 'sleep', reconnect_box)
|
||||
|
||||
await session._box_stdio_runtime._wait_for_box_runtime()
|
||||
|
||||
assert ap.box_service.available is True
|
||||
|
||||
def test_stdio_session_without_box_service_uses_local_stdio(self, mcp_module):
|
||||
ap = _make_ap()
|
||||
del ap.box_service
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
@@ -321,3 +322,42 @@ async def test_build_resource_context_for_query_uses_only_bound_attached_text_re
|
||||
assert query.variables[MCP_RESOURCE_CONTEXT_QUERY_KEY]['resource_count'] == 1
|
||||
docs.session.read_resource.assert_awaited_once()
|
||||
other.session.read_resource.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_loader_shutdown_cancels_startup_tasks_and_closes_sessions_concurrently():
|
||||
loader = MCPLoader(_app())
|
||||
hosted_cancelled = asyncio.Event()
|
||||
|
||||
async def pending_host():
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
hosted_cancelled.set()
|
||||
|
||||
hosted_task = asyncio.create_task(pending_host())
|
||||
await asyncio.sleep(0)
|
||||
loader._hosted_mcp_tasks = [hosted_task]
|
||||
|
||||
started: set[str] = set()
|
||||
all_started = asyncio.Event()
|
||||
|
||||
class Session:
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
|
||||
async def shutdown(self):
|
||||
started.add(self.name)
|
||||
if len(started) == 2:
|
||||
all_started.set()
|
||||
await all_started.wait()
|
||||
|
||||
loader.sessions = {'one': Session('one'), 'two': Session('two')}
|
||||
|
||||
await asyncio.wait_for(loader.shutdown(), timeout=1)
|
||||
|
||||
assert hosted_cancelled.is_set()
|
||||
assert hosted_task.cancelled()
|
||||
assert started == {'one', 'two'}
|
||||
assert loader._hosted_mcp_tasks == []
|
||||
assert loader.sessions == {}
|
||||
|
||||
@@ -458,6 +458,25 @@ class TestSkillToolLoader:
|
||||
assert await loader.has_tool('activate') is True
|
||||
assert await loader.has_tool('register_skill') is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tools_reappear_after_box_backend_recovers(self):
|
||||
from langbot.pkg.provider.tools.loaders.skill_authoring import SkillToolLoader
|
||||
|
||||
ap = _make_ap()
|
||||
ap.skill_mgr = SimpleNamespace(skills={'demo': _make_skill_data(name='demo')})
|
||||
ap.box_service = SimpleNamespace(
|
||||
available=False,
|
||||
get_status=AsyncMock(return_value={'backend': {'available': True}}),
|
||||
)
|
||||
|
||||
loader = SkillToolLoader(ap)
|
||||
await loader.initialize()
|
||||
assert await loader.get_tools() == []
|
||||
|
||||
ap.box_service.available = True
|
||||
|
||||
assert sorted(tool.name for tool in await loader.get_tools()) == ['activate', 'register_skill']
|
||||
|
||||
|
||||
class TestNativeToolLoaderSkillPaths:
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -151,6 +151,21 @@ async def test_native_tool_loader_exposes_all_tools_when_box_available():
|
||||
assert await loader.has_tool(tool_name) is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_native_tool_loader_refreshes_after_box_recovers():
|
||||
box_service = SimpleNamespace(
|
||||
available=False,
|
||||
get_status=AsyncMock(return_value={'backend': {'available': True}}),
|
||||
)
|
||||
loader = NativeToolLoader(SimpleNamespace(box_service=box_service, logger=Mock()))
|
||||
await loader.initialize()
|
||||
assert await loader.get_tools() == []
|
||||
|
||||
box_service.available = True
|
||||
|
||||
assert [tool.name for tool in await loader.get_tools()] == ['exec', 'read', 'write', 'edit', 'glob', 'grep']
|
||||
|
||||
|
||||
# ── read/write/edit file tool tests ─────────────────────────────
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user