Improve task resilience and LLM retry visibility

Persist and publish recoverable provider retries so temporary 503 and throttling events remain visible in live and historical task streams.

Aggregate repeated retries into an expandable action card, preserve terminal pause recovery, harden Windows worker isolation and output forwarding, and retain reliable Android hierarchy fallbacks with regression coverage.
This commit is contained in:
somew1nd committed 2026-08-20 00:37:53 -07:00
1 parent c1a4b49a21
commit 0f5fb1d32c
17 files changed
+946 -97

No files matched your search

@@ -43,3 +43,33 @@ def test_unrelated_log_is_not_presented_as_llm_failure():
assert normalized["type"] == "log"
assert normalized["status"] == "success"
def test_retrying_llm_payload_keeps_user_visible_retry_details():
repository = StepRepository()
trace = {
"trace_id": "retry-1",
"type": "llm_call",
"name": "llm_retry",
"status": "retrying",
"payload": json.dumps(
{
"error": "503 UNAVAILABLE: high demand",
"delay": 1.18,
"provider": "google",
"source": "provider_sdk",
"recoverable": True,
"internal_noise": "do not expose",
}
),
}
normalized = repository._normalize_display_trace(trace)
assert normalized["payload"] == {
"error": "503 UNAVAILABLE: high demand",
"delay": 1.18,
"provider": "google",
"source": "provider_sdk",
"recoverable": True,
}
@@ -14,6 +14,9 @@
import asyncio
import importlib
import json
import os
import subprocess
import sys
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@@ -203,9 +206,11 @@ async def test_queue_worker_cmd_construction():
assert "--app-path" in cmd
assert "/path/to/app.apk" in cmd
if sys.platform == "win32":
import subprocess
assert executed_kwargs[0]["creationflags"] == subprocess.CREATE_NEW_PROCESS_GROUP
assert executed_kwargs[0]["creationflags"] == (
subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW
)
assert executed_kwargs[0]["stdout"] == asyncio.subprocess.PIPE
assert executed_kwargs[0]["stderr"] == asyncio.subprocess.STDOUT
else:
assert "creationflags" not in executed_kwargs[0]
@@ -218,6 +223,40 @@ async def test_queue_worker_cmd_construction():
pass
@pytest.mark.asyncio
async def test_forward_worker_output_preserves_split_utf8(capsys):
stream = asyncio.StreamReader()
encoded = "worker 输出正常\n".encode()
stream.feed_data(encoded[:9])
stream.feed_data(encoded[9:])
stream.feed_eof()
await TaskQueueService._forward_worker_output(stream)
assert capsys.readouterr().out == "worker 输出正常\n"
@pytest.mark.skipif(sys.platform != "win32", reason="Windows console isolation only")
def test_windows_worker_has_no_inherited_console():
probe = r"""
import ctypes
import json
buffer = (ctypes.c_uint32 * 32)()
count = ctypes.windll.kernel32.GetConsoleProcessList(buffer, len(buffer))
print(json.dumps(list(buffer[:min(count, len(buffer))])))
"""
result = subprocess.run(
[sys.executable, "-c", probe],
check=True,
text=True,
**TaskQueueService._subprocess_creation_kwargs(),
)
attached_processes = json.loads(result.stdout)
assert os.getpid() not in attached_processes
@pytest.mark.asyncio
async def test_stop_tasks_dead_process_resets_state():
state.queue_items = [
+37
View File
@@ -17,7 +17,10 @@
import inspect
import shutil
import tempfile
from types import SimpleNamespace
import uuid
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from mcp_server.tools import (
@@ -106,6 +109,40 @@ def test_mobile_manage_task_inject_instruction(temp_trace_env):
assert "Successfully injected" in ok_res["message"]
@pytest.mark.asyncio
async def test_mobile_get_device_state_hierarchy_without_ocr():
"""XML hierarchy remains available when OCR is not configured."""
controller = MagicMock()
controller.ctx.device.device_width = 1080
controller.ctx.device.device_height = 2400
controller.get_screen_data = AsyncMock(
return_value=SimpleNamespace(
base64="ZHVtbXk=",
elements=[
{
"class": "android.widget.Button",
"text": "Start",
"bounds": "[100,500][400,600]",
}
],
width=1080,
height=2400,
)
)
with (
patch("mcp_server.tools.device_state._get_controller", return_value=controller),
patch("mcp_server.tools.device_state.is_ocr_configured", return_value=False),
patch(
"mcp_server.tools.device_state.perform_ocr", new_callable=AsyncMock
) as perform_ocr_mock,
):
result = await mobile_get_device_state(view_type="hierarchy")
assert "Start" in result
perform_ocr_mock.assert_not_awaited()
@pytest.mark.asyncio
async def test_mobile_inspect_trace_invalid_action():
res = await mobile_inspect_trace(action="invalid_action", trace_id="trace-123")
+78 -1
View File
@@ -14,14 +14,24 @@
"""Unit tests for Unified Base Device Driver and implementations."""
import pytest
import base64
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from artemis.drivers.base import KeyCode, ScreenData, SwipeDirection
from artemis.drivers.mock.mock_driver import MockDeviceDriver
from artemis.drivers.android.adb_driver import AndroidAdbDriver
_ONE_PIXEL_PNG = base64.b64encode(
base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
)
).decode("ascii")
@pytest.mark.asyncio
async def test_mock_driver_actions():
"""Verify MockDeviceDriver records and performs all core actions."""
@@ -89,3 +99,70 @@ async def test_android_driver_with_mock_adb():
# Test press key
await driver.press_key(KeyCode.BACK)
mock_adb_device.shell.assert_called_with("input keyevent 4")
@pytest.mark.asyncio
async def test_android_driver_preserves_elements_from_combined_screen_data():
"""The combined uiautomator2 response is the authoritative live snapshot."""
mock_adb_client = MagicMock()
mock_ui_client = MagicMock()
expected_elements = [
{
"text": "",
"class": "android.widget.FrameLayout",
"bounds": "[0,0][1080,2400]",
"clickable": "false",
"focusable": "false",
},
{
"text": "Start",
"class": "android.widget.Button",
"bounds": "[10,20][100,80]",
}
]
mock_ui_client.get_screen_data.return_value = SimpleNamespace(
base64=_ONE_PIXEL_PNG,
hierarchy_xml="<hierarchy />",
elements=expected_elements,
width=1080,
height=2400,
)
driver = AndroidAdbDriver(
device_id="emulator-5554",
adb_client=mock_adb_client,
ui_adb_client=mock_ui_client,
)
screen_data = await driver.get_screen_data(skip_settling=True)
assert screen_data.ui_elements == [expected_elements[1]]
assert screen_data.ui_hierarchy_xml == "<hierarchy />"
mock_ui_client.get_hierarchy.assert_not_called()
@pytest.mark.asyncio
async def test_android_driver_parses_xml_hierarchy_fallback():
"""XML-only clients remain supported without discarding their hierarchy."""
mock_adb_client = MagicMock()
mock_ui_client = MagicMock()
mock_ui_client.get_screen_data.return_value = SimpleNamespace(
base64=_ONE_PIXEL_PNG,
width=1080,
height=2400,
)
mock_ui_client.get_hierarchy.return_value = (
'<hierarchy><node text="Settings" class="android.widget.TextView" '
'bounds="[0,0][100,100]" /></hierarchy>'
)
driver = AndroidAdbDriver(
device_id="emulator-5554",
adb_client=mock_adb_client,
ui_adb_client=mock_ui_client,
)
screen_data = await driver.get_screen_data(skip_settling=True)
assert screen_data.ui_hierarchy_xml is not None
assert any(element.get("text") == "Settings" for element in screen_data.ui_elements)
+43
View File
@@ -1,8 +1,11 @@
import logging
from artemis.services import llm
class _RecordingEngine:
current_step_id = "step-7"
current_session_id = "session-1"
def __init__(self):
self.trace_kwargs = None
@@ -10,6 +13,7 @@ class _RecordingEngine:
def record_trace(self, **kwargs):
self.trace_kwargs = kwargs
return "trace-1"
def _publish(self, event_type, payload):
self.published = (event_type, payload)
@@ -35,3 +39,42 @@ def test_pause_error_is_persisted_and_published(monkeypatch, tmp_path):
assert payload["error"] == "503 high demand"
assert payload["step_id"] == "step-7"
assert isinstance(payload["timestamp"], float)
def test_provider_sdk_retry_is_persisted_and_published(monkeypatch):
engine = _RecordingEngine()
monkeypatch.setattr(llm, "_CURRENT_DATA_ENGINE", engine)
record = logging.LogRecord(
name="google_genai._api_client",
level=logging.INFO,
pathname=__file__,
lineno=1,
msg=(
"Retrying google.genai request in 1.18 seconds as it raised "
"ServerError: 503 UNAVAILABLE: high demand"
),
args=(),
exc_info=None,
)
llm._ProviderRetryTelemetryHandler().emit(record)
assert engine.trace_kwargs == {
"type": "llm_call",
"name": "llm_retry",
"payload": {
"error": "ServerError: 503 UNAVAILABLE: high demand",
"delay": 1.18,
"provider": "google",
"source": "provider_sdk",
"recoverable": True,
},
"step_id": "step-7",
"parent_trace_id": None,
"status": "retrying",
}
event_type, payload = engine.published
assert event_type == "llm_retrying"
assert payload["trace_id"] == "trace-1"
assert payload["delay"] == 1.18
assert payload["error"].startswith("ServerError: 503")