Skip to content

Commit d68d3cc

Browse files
committed
fix: treat timer triggers as external resume triggers
1 parent b5d481d commit d68d3cc

6 files changed

Lines changed: 148 additions & 13 deletions

File tree

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
[project]
22
name = "uipath-runtime"
3-
version = "0.11.4"
3+
version = "0.11.5"
44
description = "Runtime abstractions and interfaces for building agents and automation scripts in the UiPath ecosystem"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"
77
dependencies = [
8-
"uipath-core>=0.5.22,<0.6.0",
8+
"uipath-core>=0.5.26,<0.6.0",
99
]
1010
classifiers = [
1111
"Intended Audience :: Developers",

src/uipath/runtime/debug/runtime.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -194,9 +194,10 @@ async def _stream_and_debug(
194194
resume_data: dict[str, Any] | None = None
195195
try:
196196
trigger_data: dict[str, Any] | None = None
197-
if (
198-
final_result.trigger.trigger_type
199-
== UiPathResumeTriggerType.API
197+
if final_result.trigger.trigger_type in (
198+
UiPathResumeTriggerType.API,
199+
UiPathResumeTriggerType.INBOX,
200+
UiPathResumeTriggerType.TIMER,
200201
):
201202
trigger_data = (
202203
await self.debug_bridge.wait_for_resume()

src/uipath/runtime/resumable/runtime.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -147,8 +147,9 @@ async def stream(
147147
async def _get_fired_triggers(self) -> dict[str, Any] | None:
148148
"""Check stored triggers for any that have already fired.
149149
150-
Skips async-external triggers (API, Inbox) whose payloads only arrive
151-
asynchronously and cannot be polled at suspend time.
150+
Skips external triggers (API, Inbox, Timer) whose payloads only arrive
151+
asynchronously or through Orchestrator resume and cannot be polled at
152+
suspend time.
152153
153154
Returns:
154155
A resume map of {interrupt_id: resume_data} for fired triggers, or None.
@@ -161,7 +162,11 @@ async def _get_fired_triggers(self) -> dict[str, Any] | None:
161162
t
162163
for t in triggers
163164
if t.trigger_type
164-
not in (UiPathResumeTriggerType.API, UiPathResumeTriggerType.INBOX)
165+
not in (
166+
UiPathResumeTriggerType.API,
167+
UiPathResumeTriggerType.INBOX,
168+
UiPathResumeTriggerType.TIMER,
169+
)
165170
]
166171
return await self._build_resume_map(pollable_triggers)
167172

tests/test_debugger.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from unittest.mock import AsyncMock, Mock
77

88
import pytest
9+
from uipath.core.triggers import UiPathResumeTrigger, UiPathResumeTriggerType
910

1011
from uipath.runtime import (
1112
UiPathBreakpointResult,
@@ -131,6 +132,48 @@ async def get_schema(self) -> UiPathRuntimeSchema:
131132
raise NotImplementedError()
132133

133134

135+
class SuspendedThenSuccessfulRuntime:
136+
"""Mock runtime that suspends once and completes after resume."""
137+
138+
def __init__(self, trigger: UiPathResumeTrigger) -> None:
139+
self.trigger = trigger
140+
self.inputs: list[dict[str, Any] | None] = []
141+
self.options: list[UiPathStreamOptions | None] = []
142+
143+
async def dispose(self) -> None:
144+
pass
145+
146+
async def execute(
147+
self,
148+
input: dict[str, Any] | None = None,
149+
options: UiPathExecuteOptions | None = None,
150+
) -> UiPathRuntimeResult:
151+
raise NotImplementedError()
152+
153+
async def stream(
154+
self,
155+
input: dict[str, Any] | None = None,
156+
options: UiPathStreamOptions | None = None,
157+
) -> AsyncGenerator[UiPathRuntimeEvent, None]:
158+
self.inputs.append(input)
159+
self.options.append(options)
160+
161+
if options and options.resume:
162+
yield UiPathRuntimeResult(
163+
status=UiPathRuntimeStatus.SUCCESSFUL,
164+
output={"resumed_with": input},
165+
)
166+
return
167+
168+
yield UiPathRuntimeResult(
169+
status=UiPathRuntimeStatus.SUSPENDED,
170+
trigger=self.trigger,
171+
)
172+
173+
async def get_schema(self) -> UiPathRuntimeSchema:
174+
raise NotImplementedError()
175+
176+
134177
@pytest.mark.asyncio
135178
async def test_debug_runtime_streams_and_handles_breakpoints_and_state():
136179
"""UiPathDebugRuntime should stream events, handle breakpoints and state updates."""
@@ -169,6 +212,51 @@ async def test_debug_runtime_streams_and_handles_breakpoints_and_state():
169212
) # initial + after breakpoint
170213

171214

215+
@pytest.mark.asyncio
216+
async def test_debug_runtime_waits_for_timer_resume_without_polling():
217+
"""Timer triggers should wait for external resume in debug mode."""
218+
trigger = UiPathResumeTrigger(
219+
interrupt_id="timer-interrupt",
220+
trigger_type=UiPathResumeTriggerType.TIMER,
221+
payload={"kind": "timeout"},
222+
)
223+
runtime_impl = SuspendedThenSuccessfulRuntime(trigger)
224+
bridge = make_debug_bridge_mock()
225+
cast(AsyncMock, bridge.wait_for_resume).side_effect = [
226+
None,
227+
{"__uipath": {"kind": "timeout"}},
228+
]
229+
cast(Mock, bridge.get_breakpoints).return_value = []
230+
231+
trigger_manager = Mock()
232+
trigger_manager.read_trigger = AsyncMock(
233+
side_effect=AssertionError("Timer triggers must not be polled")
234+
)
235+
236+
debug_runtime = UiPathDebugRuntime(
237+
delegate=runtime_impl,
238+
debug_bridge=bridge,
239+
)
240+
debug_runtime.get_resumable_runtime = Mock( # type: ignore[method-assign]
241+
return_value=Mock(trigger_manager=trigger_manager)
242+
)
243+
244+
result = await debug_runtime.execute({})
245+
246+
assert result.status == UiPathRuntimeStatus.SUCCESSFUL
247+
assert result.output == {
248+
"resumed_with": {
249+
"timer-interrupt": {"__uipath": {"kind": "timeout"}},
250+
},
251+
}
252+
assert cast(AsyncMock, bridge.wait_for_resume).await_count == 2
253+
cast(AsyncMock, bridge.emit_execution_suspended).assert_awaited_once()
254+
cast(AsyncMock, bridge.emit_execution_resumed).assert_awaited_once_with(
255+
{"timer-interrupt": {"__uipath": {"kind": "timeout"}}}
256+
)
257+
trigger_manager.read_trigger.assert_not_awaited()
258+
259+
172260
@pytest.mark.asyncio
173261
async def test_debug_runtime_falls_back_when_stream_not_supported():
174262
"""If runtime raises UiPathStreamNotSupportedError, we fall back to execute()."""

tests/test_resumable.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,47 @@ def create_inbox_trigger(data: dict[str, Any]) -> UiPathResumeTrigger:
548548
trigger_manager.read_trigger.assert_not_called()
549549
assert runtime_impl.execution_count == 1
550550

551+
@pytest.mark.asyncio
552+
async def test_resumable_skips_timer_triggers_on_auto_resume_check(self) -> None:
553+
"""Timer triggers should be skipped when checking for auto-resume."""
554+
555+
runtime_impl = MultiTriggerMockRuntime()
556+
storage = StatefulStorageMock()
557+
trigger_manager = make_trigger_manager_mock()
558+
559+
def create_timer_trigger(data: dict[str, Any]) -> UiPathResumeTrigger:
560+
return UiPathResumeTrigger(
561+
interrupt_id="", # Will be set by resumable runtime
562+
trigger_type=UiPathResumeTriggerType.TIMER,
563+
payload=data,
564+
)
565+
566+
trigger_manager.create_trigger = AsyncMock(side_effect=create_timer_trigger) # type: ignore[method-assign]
567+
read_trigger_guard = AsyncMock(
568+
side_effect=AssertionError(
569+
"read_trigger must not be called for Timer triggers pre-resume"
570+
)
571+
)
572+
trigger_manager.read_trigger = read_trigger_guard # type: ignore[method-assign]
573+
574+
resumable = UiPathResumableRuntime(
575+
delegate=runtime_impl,
576+
storage=storage,
577+
trigger_manager=trigger_manager,
578+
runtime_id="runtime-1",
579+
)
580+
581+
result = await resumable.execute({})
582+
583+
assert result.status == UiPathRuntimeStatus.SUSPENDED
584+
assert result.triggers is not None
585+
assert len(result.triggers) == 2
586+
assert all(
587+
t.trigger_type == UiPathResumeTriggerType.TIMER for t in result.triggers
588+
)
589+
trigger_manager.read_trigger.assert_not_called()
590+
assert runtime_impl.execution_count == 1
591+
551592
@pytest.mark.asyncio
552593
async def test_resumable_auto_resumes_task_triggers_but_not_api_triggers(
553594
self,

uv.lock

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)