|
9 | 9 | import mcp.types as types |
10 | 10 | from mcp import ClientSession, StdioServerParameters |
11 | 11 | from opentelemetry import trace |
12 | | -from pysignalr.client import SignalRClient |
| 12 | +from pysignalr.client import CompletionMessage, SignalRClient |
13 | 13 | from uipath import UiPath |
14 | 14 | from uipath._cli._runtime._contracts import ( |
15 | 15 | UiPathBaseRuntime, |
@@ -42,6 +42,7 @@ def __init__(self, context: UiPathMcpRuntimeContext): |
42 | 42 | self._session_servers: Dict[str, SessionServer] = {} |
43 | 43 | self._session_output: Optional[str] = None |
44 | 44 | self._cancel_event = asyncio.Event() |
| 45 | + self._keep_alive_task: Optional[asyncio.Task] = None |
45 | 46 | self._uipath = UiPath() |
46 | 47 |
|
47 | 48 | async def execute(self) -> Optional[UiPathRuntimeResult]: |
@@ -91,6 +92,8 @@ async def execute(self) -> Optional[UiPathRuntimeResult]: |
91 | 92 | # Set up a task to wait for cancellation |
92 | 93 | cancel_task = asyncio.create_task(self._cancel_event.wait()) |
93 | 94 |
|
| 95 | + self._keep_alive_task = asyncio.create_task(self._keep_alive()) |
| 96 | + |
94 | 97 | # Keep the runtime alive |
95 | 98 | # Wait for either the run to complete or cancellation |
96 | 99 | done, pending = await asyncio.wait( |
@@ -138,6 +141,13 @@ async def cleanup(self) -> None: |
138 | 141 |
|
139 | 142 | await self._on_runtime_abort() |
140 | 143 |
|
| 144 | + if self._keep_alive_task: |
| 145 | + self._keep_alive_task.cancel() |
| 146 | + try: |
| 147 | + await self._keep_alive_task |
| 148 | + except asyncio.CancelledError: |
| 149 | + pass |
| 150 | + |
141 | 151 | for session_id, session_server in self._session_servers.items(): |
142 | 152 | try: |
143 | 153 | await session_server.stop() |
@@ -276,9 +286,6 @@ async def _register(self) -> None: |
276 | 286 | stderr_temp.seek(0) |
277 | 287 | server_stderr_output = stderr_temp.read().decode('utf-8', errors='replace') |
278 | 288 | # We'll handle this after exiting the context managers |
279 | | - logger.info("Exiting client session context") |
280 | | - logger.info("Exiting stdio client context") |
281 | | - logger.info("Exiting temporary file context") |
282 | 289 | # We don't continue with registration here - we'll do it after the context managers |
283 | 290 |
|
284 | 291 | except BaseException as e: |
@@ -369,6 +376,31 @@ async def _on_session_start_error(self, session_id: str) -> None: |
369 | 376 | f"Error sending session dispose signal to UiPath MCP Server: {e}" |
370 | 377 | ) |
371 | 378 |
|
| 379 | + async def _keep_alive(self) -> None: |
| 380 | + """ |
| 381 | + Heartbeat to keep the runtime available. |
| 382 | + """ |
| 383 | + while True: |
| 384 | + try: |
| 385 | + async def on_keep_alive_response(response: CompletionMessage) -> None: |
| 386 | + session_ids = response.result |
| 387 | + logger.info(f"Active sessions: {session_ids}") |
| 388 | + # If there are no active sessions and this is a sandbox environment |
| 389 | + # We need to cancel the runtime |
| 390 | + # eg: when user kills the agent that triggered the runtime, before we subscribe to events |
| 391 | + if not session_ids and self.sandboxed and not self._cancel_event.is_set(): |
| 392 | + logger.error("No active sessions, cancelling sandboxed runtime...") |
| 393 | + self._cancel_event.set() |
| 394 | + await self._signalr_client.send( |
| 395 | + method="OnKeepAlive", |
| 396 | + arguments=[], |
| 397 | + on_invocation=on_keep_alive_response |
| 398 | + ) |
| 399 | + except Exception as e: |
| 400 | + logger.error(f"Error during keep-alive: {e}") |
| 401 | + await asyncio.sleep(60) |
| 402 | + |
| 403 | + |
372 | 404 | async def _on_runtime_abort(self) -> None: |
373 | 405 | """ |
374 | 406 | Sends a runtime abort signalr to terminate all connected sessions. |
|
0 commit comments