From a464b9511584c03d59a3d6efe981eb2f9ae39c75 Mon Sep 17 00:00:00 2001 From: Andrei Tava Date: Wed, 19 Aug 2026 14:51:54 +0300 Subject: [PATCH 1/6] fix(debug): continue execution when initial resume wait times out --- src/uipath/runtime/debug/runtime.py | 7 ++++--- tests/test_debugger.py | 25 +++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/uipath/runtime/debug/runtime.py b/src/uipath/runtime/debug/runtime.py index a64606a4..c300cb06 100644 --- a/src/uipath/runtime/debug/runtime.py +++ b/src/uipath/runtime/debug/runtime.py @@ -123,11 +123,12 @@ async def _stream_and_debug( try: await asyncio.wait_for(self.debug_bridge.wait_for_resume(), timeout=60.0) except asyncio.TimeoutError: + # Debug bridge likely disconnected: proceed unattended + # instead of failing the job. logger.warning( - "Initial resume wait timed out after 60s, assuming debug bridge disconnected" + "Initial resume wait timed out after 60s, assuming debug bridge " + "disconnected; continuing execution without debug commands" ) - yield UiPathRuntimeResult(status=UiPathRuntimeStatus.FAULTED) - return except UiPathDebugQuitError: logger.info("Debug session quit by user before execution started") yield UiPathRuntimeResult(status=UiPathRuntimeStatus.SUCCESSFUL) diff --git a/tests/test_debugger.py b/tests/test_debugger.py index cd82d2e7..7cd8ecbc 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,30 @@ 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 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() + cast(Mock, bridge.get_breakpoints).return_value = [] + + 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.emit_execution_completed).assert_awaited_once_with(result) + + @pytest.mark.asyncio async def test_debug_runtime_waits_for_timer_resume_without_polling(): """Timer triggers should wait for external resume in debug mode.""" From 7f1dbea8e635ba31977d437f1dbc61f40e6ab219 Mon Sep 17 00:00:00 2001 From: Andrei Tava Date: Wed, 19 Aug 2026 14:58:34 +0300 Subject: [PATCH 2/6] refactor(debug): extract initial resume timeout constant --- src/uipath/runtime/debug/runtime.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/uipath/runtime/debug/runtime.py b/src/uipath/runtime/debug/runtime.py index c300cb06..8373ba92 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,13 +123,16 @@ 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. logger.warning( - "Initial resume wait timed out after 60s, assuming debug bridge " - "disconnected; continuing execution without debug commands" + f"Initial resume wait timed out after {INITIAL_RESUME_TIMEOUT_SECONDS:g}s, " + "assuming debug bridge disconnected; continuing execution without debug commands" ) except UiPathDebugQuitError: logger.info("Debug session quit by user before execution started") From 339c623fcdedae5286afa76185e13a50aad1b64d Mon Sep 17 00:00:00 2001 From: Andrei Tava Date: Tue, 25 Aug 2026 12:56:18 +0300 Subject: [PATCH 3/6] fix(debug): disconnect bridge and run unattended after initial resume wait timeout --- src/uipath/runtime/debug/runtime.py | 35 +++++++++++++++++++++---- tests/test_debugger.py | 40 +++++++++++++++++++++++++++-- 2 files changed, 68 insertions(+), 7 deletions(-) diff --git a/src/uipath/runtime/debug/runtime.py b/src/uipath/runtime/debug/runtime.py index 8373ba92..f7d53ae5 100644 --- a/src/uipath/runtime/debug/runtime.py +++ b/src/uipath/runtime/debug/runtime.py @@ -120,6 +120,7 @@ async def _stream_and_debug( """Stream events from inner runtime and handle debug interactions.""" final_result: UiPathRuntimeResult execution_completed = False + bridge_disconnected = False # Starting in paused state - wait for breakpoints and resume try: @@ -129,11 +130,20 @@ async def _stream_and_debug( ) except asyncio.TimeoutError: # Debug bridge likely disconnected: proceed unattended - # instead of failing the job. + # instead of failing the job. Drop the bridge entirely so a stale + # breakpoint set or a late command can never pause the run, and any + # further wait on debug commands would hang forever. logger.warning( f"Initial resume wait timed out after {INITIAL_RESUME_TIMEOUT_SECONDS:g}s, " - "assuming debug bridge disconnected; continuing execution without debug commands" + "assuming debug bridge disconnected; disconnecting the bridge and " + "continuing execution unattended" ) + bridge_disconnected = True + try: + await self.debug_bridge.disconnect() + logger.info("Debug bridge disconnected") + except Exception as e: + logger.warning(f"Error disconnecting debug bridge: {e}") except UiPathDebugQuitError: logger.info("Debug session quit by user before execution started") yield UiPathRuntimeResult(status=UiPathRuntimeStatus.SUCCESSFUL) @@ -148,8 +158,11 @@ async def _stream_and_debug( # Keep streaming until execution completes (not just paused at breakpoint) while not execution_completed: - # Update breakpoints from debug bridge - debug_options.breakpoints = self.debug_bridge.get_breakpoints() + # Update breakpoints from debug bridge; with the bridge disconnected + # run without breakpoints so the delegate can never pause on them + debug_options.breakpoints = ( + None if bridge_disconnected else self.debug_bridge.get_breakpoints() + ) # Stream events from inner runtime async for event in self.delegate.stream( @@ -183,8 +196,20 @@ async def _stream_and_debug( else: # Normal completion or suspension with dynamic interrupt - # Check if this is a suspended execution that needs polling if ( + bridge_disconnected + and final_result.status == UiPathRuntimeStatus.SUSPENDED + ): + # Bridge is gone, so debug commands or inline polling + # can never resume this run: end as suspended and let + # the platform resume it via the real trigger + logger.info( + "Execution suspended with debug bridge disconnected, " + "completing as suspended" + ) + execution_completed = True + # Check if this is a suspended execution that needs polling + elif ( (resumable_runtime := self.get_resumable_runtime()) and self.trigger_poll_interval > 0 and final_result.status == UiPathRuntimeStatus.SUSPENDED diff --git a/tests/test_debugger.py b/tests/test_debugger.py index 7cd8ecbc..0d531777 100644 --- a/tests/test_debugger.py +++ b/tests/test_debugger.py @@ -216,14 +216,16 @@ async def test_debug_runtime_streams_and_handles_breakpoints_and_state(): @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 continue unattended instead of faulting.""" + 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() - cast(Mock, bridge.get_breakpoints).return_value = [] + # 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, @@ -234,9 +236,43 @@ async def test_debug_runtime_continues_when_initial_resume_wait_times_out(): 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_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.""" From ad2cfa3bd04d75b8fa16a33dac429820d241c8a3 Mon Sep 17 00:00:00 2001 From: Andrei Tava Date: Tue, 25 Aug 2026 13:41:29 +0300 Subject: [PATCH 4/6] chore: bump version to 0.13.3 (0.13.2 taken on main) --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/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" }, From f61331bacee0f0494feb450b34feb79027ae7e5b Mon Sep 17 00:00:00 2001 From: Andrei Tava Date: Tue, 25 Aug 2026 13:56:53 +0300 Subject: [PATCH 5/6] test: cover disconnect failure after initial resume wait timeout --- tests/test_debugger.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/test_debugger.py b/tests/test_debugger.py index 0d531777..0c613a16 100644 --- a/tests/test_debugger.py +++ b/tests/test_debugger.py @@ -242,6 +242,28 @@ async def test_debug_runtime_continues_when_initial_resume_wait_times_out(): 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 From 6dcec7f097fa2f2844935f18879c970aa0c0c9b7 Mon Sep 17 00:00:00 2001 From: Andrei Tava Date: Tue, 25 Aug 2026 17:03:40 +0300 Subject: [PATCH 6/6] refactor(debug): early-return unattended pass-through after resume wait timeout --- src/uipath/runtime/debug/runtime.py | 43 +++++++++++++---------------- 1 file changed, 19 insertions(+), 24 deletions(-) diff --git a/src/uipath/runtime/debug/runtime.py b/src/uipath/runtime/debug/runtime.py index f7d53ae5..57ef5ec6 100644 --- a/src/uipath/runtime/debug/runtime.py +++ b/src/uipath/runtime/debug/runtime.py @@ -120,7 +120,6 @@ async def _stream_and_debug( """Stream events from inner runtime and handle debug interactions.""" final_result: UiPathRuntimeResult execution_completed = False - bridge_disconnected = False # Starting in paused state - wait for breakpoints and resume try: @@ -129,21 +128,32 @@ async def _stream_and_debug( timeout=INITIAL_RESUME_TIMEOUT_SECONDS, ) except asyncio.TimeoutError: - # Debug bridge likely disconnected: proceed unattended - # instead of failing the job. Drop the bridge entirely so a stale - # breakpoint set or a late command can never pause the run, and any - # further wait on debug commands would hang forever. + # 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( f"Initial resume wait timed out after {INITIAL_RESUME_TIMEOUT_SECONDS:g}s, " "assuming debug bridge disconnected; disconnecting the bridge and " "continuing execution unattended" ) - bridge_disconnected = True 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") yield UiPathRuntimeResult(status=UiPathRuntimeStatus.SUCCESSFUL) @@ -158,11 +168,8 @@ async def _stream_and_debug( # Keep streaming until execution completes (not just paused at breakpoint) while not execution_completed: - # Update breakpoints from debug bridge; with the bridge disconnected - # run without breakpoints so the delegate can never pause on them - debug_options.breakpoints = ( - None if bridge_disconnected else self.debug_bridge.get_breakpoints() - ) + # Update breakpoints from debug bridge + debug_options.breakpoints = self.debug_bridge.get_breakpoints() # Stream events from inner runtime async for event in self.delegate.stream( @@ -196,20 +203,8 @@ async def _stream_and_debug( else: # Normal completion or suspension with dynamic interrupt - if ( - bridge_disconnected - and final_result.status == UiPathRuntimeStatus.SUSPENDED - ): - # Bridge is gone, so debug commands or inline polling - # can never resume this run: end as suspended and let - # the platform resume it via the real trigger - logger.info( - "Execution suspended with debug bridge disconnected, " - "completing as suspended" - ) - execution_completed = True # Check if this is a suspended execution that needs polling - elif ( + if ( (resumable_runtime := self.get_resumable_runtime()) and self.trigger_poll_interval > 0 and final_result.status == UiPathRuntimeStatus.SUSPENDED