diff --git a/pyproject.toml b/pyproject.toml index 8b5e5dc3..77a37c51 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-runtime" -version = "0.13.2" +version = "0.13.3" description = "Runtime abstractions and interfaces for building agents and automation scripts in the UiPath ecosystem" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/src/uipath/runtime/debug/runtime.py b/src/uipath/runtime/debug/runtime.py index a64606a4..57ef5ec6 100644 --- a/src/uipath/runtime/debug/runtime.py +++ b/src/uipath/runtime/debug/runtime.py @@ -35,6 +35,8 @@ logger = logging.getLogger(__name__) +INITIAL_RESUME_TIMEOUT_SECONDS = 60.0 + class UiPathDebugRuntime: """Specialized runtime for debug runs that streams events to a debug bridge.""" @@ -121,12 +123,36 @@ async def _stream_and_debug( # Starting in paused state - wait for breakpoints and resume try: - await asyncio.wait_for(self.debug_bridge.wait_for_resume(), timeout=60.0) + await asyncio.wait_for( + self.debug_bridge.wait_for_resume(), + timeout=INITIAL_RESUME_TIMEOUT_SECONDS, + ) except asyncio.TimeoutError: + # Debug bridge likely disconnected: proceed unattended instead of + # failing the job. Drop the bridge so a stale breakpoint set or a + # late command can never pause the run, then run the delegate in a + # single pass-through: no breakpoints, no inline resume handling, + # and a suspension is terminal (the platform resumes it via the + # real trigger). logger.warning( - "Initial resume wait timed out after 60s, assuming debug bridge disconnected" + f"Initial resume wait timed out after {INITIAL_RESUME_TIMEOUT_SECONDS:g}s, " + "assuming debug bridge disconnected; disconnecting the bridge and " + "continuing execution unattended" ) - yield UiPathRuntimeResult(status=UiPathRuntimeStatus.FAULTED) + try: + await self.debug_bridge.disconnect() + logger.info("Debug bridge disconnected") + except Exception as e: + logger.warning(f"Error disconnecting debug bridge: {e}") + + async for event in self.delegate.stream( + input, + options=UiPathStreamOptions( + resume=options.resume if options else False, + breakpoints=None, + ), + ): + yield event return except UiPathDebugQuitError: logger.info("Debug session quit by user before execution started") diff --git a/tests/test_debugger.py b/tests/test_debugger.py index cd82d2e7..0c613a16 100644 --- a/tests/test_debugger.py +++ b/tests/test_debugger.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from typing import Any, AsyncGenerator, Sequence, cast from unittest.mock import AsyncMock, Mock @@ -212,6 +213,88 @@ async def test_debug_runtime_streams_and_handles_breakpoints_and_state(): ) # initial + after breakpoint +@pytest.mark.asyncio +async def test_debug_runtime_continues_when_initial_resume_wait_times_out(): + """If no resume command arrives before the initial wait times out, + execution should disconnect the bridge and continue unattended + instead of faulting.""" + + runtime_impl = StreamingMockRuntime(node_sequence=["node-1", "node-2"]) + bridge = make_debug_bridge_mock() + + # Initial resume wait times out (debug bridge disconnected) + cast(AsyncMock, bridge.wait_for_resume).side_effect = asyncio.TimeoutError() + # Stale breakpoints must not be honored once the bridge is dropped + cast(Mock, bridge.get_breakpoints).return_value = ["node-1", "node-2"] + + debug_runtime = UiPathDebugRuntime( + delegate=runtime_impl, + debug_bridge=bridge, + ) + + result = await debug_runtime.execute({}) + + assert result.status == UiPathRuntimeStatus.SUCCESSFUL + assert result.output == {"visited_nodes": ["node-1", "node-2"]} + cast(AsyncMock, bridge.disconnect).assert_awaited_once() + cast(Mock, bridge.get_breakpoints).assert_not_called() + cast(AsyncMock, bridge.emit_breakpoint_hit).assert_not_awaited() + cast(AsyncMock, bridge.emit_execution_completed).assert_awaited_once_with(result) + + +@pytest.mark.asyncio +async def test_debug_runtime_survives_disconnect_error_after_resume_wait_timeout(): + """A failing bridge disconnect after the timeout must not fault the run.""" + + runtime_impl = StreamingMockRuntime(node_sequence=["node-1"]) + bridge = make_debug_bridge_mock() + cast(AsyncMock, bridge.wait_for_resume).side_effect = asyncio.TimeoutError() + cast(AsyncMock, bridge.disconnect).side_effect = RuntimeError( + "socket already closed" + ) + + debug_runtime = UiPathDebugRuntime( + delegate=runtime_impl, + debug_bridge=bridge, + ) + + result = await debug_runtime.execute({}) + + assert result.status == UiPathRuntimeStatus.SUCCESSFUL + assert result.output == {"visited_nodes": ["node-1"]} + + +@pytest.mark.asyncio +async def test_debug_runtime_completes_as_suspended_after_resume_wait_timeout(): + """After the initial resume wait times out, a suspension must be terminal + (the platform resumes via the real trigger) instead of waiting on debug + commands from the disconnected bridge.""" + + trigger = UiPathResumeTrigger( + interrupt_id="api-interrupt", + trigger_type=UiPathResumeTriggerType.API, + ) + runtime_impl = SuspendedThenSuccessfulRuntime(trigger) + bridge = make_debug_bridge_mock() + cast(AsyncMock, bridge.wait_for_resume).side_effect = asyncio.TimeoutError() + + debug_runtime = UiPathDebugRuntime( + delegate=runtime_impl, + debug_bridge=bridge, + ) + debug_runtime.get_resumable_runtime = Mock( # type: ignore[method-assign] + return_value=Mock(trigger_manager=Mock()) + ) + + result = await debug_runtime.execute({}) + + assert result.status == UiPathRuntimeStatus.SUSPENDED + assert result.trigger is trigger + # Only the initial wait; no resume wait for the suspension + assert cast(AsyncMock, bridge.wait_for_resume).await_count == 1 + cast(AsyncMock, bridge.emit_execution_suspended).assert_not_awaited() + + @pytest.mark.asyncio async def test_debug_runtime_waits_for_timer_resume_without_polling(): """Timer triggers should wait for external resume in debug mode.""" diff --git a/uv.lock b/uv.lock index 97acb1ef..3625ed5c 100644 --- a/uv.lock +++ b/uv.lock @@ -1153,7 +1153,7 @@ wheels = [ [[package]] name = "uipath-runtime" -version = "0.13.2" +version = "0.13.3" source = { editable = "." } dependencies = [ { name = "chardet" },