fix: bound task failures and release stalled workers

This commit is contained in:
chenmingxuan committed 2026-10-08 16:10:10 +08:00
1 parent 606ed94160
commit 1fdf7c0d1d
32 files changed
+1121 -93

No files matched your search

+72 -17
View File
@@ -81,7 +81,7 @@ from artemis.graph.state import State
from artemis.llm.router import ModelEndpoint
from artemis.llm.structured import ParseFailure, parse_structured
from artemis.mcp.action_executor import McpActionExecutor
from artemis.mcp.observation import observe
from artemis.mcp.observation import DeviceObservationError, require_observation
from artemis.memory.transcript import PRO_UI_LIST_MARKER, TranscriptLedger, mark_ephemeral
from artemis.services.llm import (
RobustChatModelWrapper,
@@ -380,8 +380,16 @@ class FlashRunner:
blocks.append(
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{img_b64}"}}
)
if xml_list:
blocks.append({"type": "text", "text": f"{PRO_UI_LIST_MARKER}\n{xml_list}"})
blocks.append(
{
"type": "text",
"text": f"{PRO_UI_LIST_MARKER}\n"
+ (
xml_list
or "当前没有可用的元素索引。仅根据当前截图定位坐标,禁止使用历史画面的元素编号。"
),
}
)
ephemeral: list[int] = []
# (text, ephemeral) in tail order.
per_turn: list[tuple[str, bool]] = [(text, True) for text in (notices or [])]
@@ -604,19 +612,14 @@ class FlashRunner:
post_img_bytes = exec_result.screenshot_bytes
if post_img_bytes or name not in action_names:
return post_img_bytes
try:
obs, screenshot_bytes = await observe(self.ctx, self.controller, settle_ms=0)
except Exception as shot_err:
logger.warning(f"Failed to capture fallback screenshot in FlashRunner: {shot_err}")
return post_img_bytes
if not obs.ok:
logger.warning(f"Failed to capture fallback screenshot in FlashRunner: {obs.message}")
return post_img_bytes
if state is not None:
state.indexed_points = []
state.indexed_elements = []
state.latest_screenshot = None
obs, screenshot_bytes = await require_observation(self.ctx, self.controller, settle_ms=0)
post_img_bytes = screenshot_bytes
if not exec_result.ui_elements_text:
exec_result.ui_elements_text = obs.elements_text
if not exec_result.screenshot_path and obs.screenshot_path:
exec_result.screenshot_path = obs.screenshot_path
exec_result.ui_elements_text = obs.elements_text
exec_result.screenshot_path = obs.screenshot_path
if state is not None:
if obs.hierarchy_ok:
state.indexed_points = [el["center"] for el in obs.elements]
@@ -831,7 +834,7 @@ class FlashRunner:
turn.actions.append((name, exec_result.status, exec_result.text_summary))
# ⚡ Non-blocking dispatch of objective visual transition summarizer
if self.summarizer and name in action_names:
if self.summarizer and name in action_names and post_img_bytes:
action_sequence += 1
self.summarizer.dispatch(
step_number=action_sequence,
@@ -857,7 +860,7 @@ class FlashRunner:
if post_img_bytes:
pre_screenshot_bytes = post_img_bytes
if exec_result.ui_elements_text:
if exec_result.ui_elements_text is not None:
xml_list = exec_result.ui_elements_text
# Helper tools may return multimodal blocks (a step screenshot);
@@ -876,6 +879,8 @@ class FlashRunner:
)
)
except DeviceObservationError:
raise
except Exception as e:
logger.error(f"Error executing tool {name}: {e}")
if name in action_names:
@@ -1047,6 +1052,21 @@ class FlashRunner:
@trace(type="agent", name="FlashRunner")
async def run(self, state: State) -> dict:
try:
return await self._run(state)
except DeviceObservationError as exc:
state.indexed_points = []
state.indexed_elements = []
state.latest_screenshot = None
logger.error(f"Flash 因设备观测失败停止:{exc}")
return {
"status": "failed",
"explanation": str(exc),
"error": str(exc),
"code": "device_observation_failed",
}
async def _run(self, state: State) -> dict:
limit = self.turn_limit
logger.info(
f"Starting Artemis Flash reactive loop for goal: {self.goal}"
@@ -1073,6 +1093,7 @@ class FlashRunner:
no_tool_limit = settings.ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS
fallback_llm = None
using_tool_fallback = False
action_failure_attempts = 0
while limit is None or turns < limit:
control = getattr(self.ctx, "task_control", None)
@@ -1084,6 +1105,7 @@ class FlashRunner:
if resumed:
using_tool_fallback = False
no_tool_attempts = 0
action_failure_attempts = 0
self._llm = llm
(
shot_path,
@@ -1157,6 +1179,7 @@ class FlashRunner:
# assembled before the instruction arrived.
using_tool_fallback = False
no_tool_attempts = 0
action_failure_attempts = 0
self._llm = llm
(
shot_path,
@@ -1241,6 +1264,7 @@ class FlashRunner:
fallback_llm = get_llm(self.ctx, name="operator", use_fallback=True)
using_tool_fallback = True
no_tool_attempts = 0
action_failure_attempts = 0
logger.warning("Flash 首选模型未调用工具,当前步骤切换到兜底模型")
pending_notices.append("首选模型连续未调用工具;本步骤改用兜底模型。")
pending_notices.append(_NO_TOOL_CALL_NOTICE)
@@ -1279,6 +1303,37 @@ class FlashRunner:
using_tool_fallback = False
self._llm = llm
pending_notices.append("兜底模型已完成本步骤;后续步骤恢复首选模型。")
for _, status, _ in turn.actions:
action_failure_attempts = 0 if status == "success" else action_failure_attempts + 1
failure_limit = (
settings.ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS
if using_tool_fallback
else settings.ARTEMIS_ACTION_FAILURE_ATTEMPTS
)
if action_failure_attempts >= failure_limit:
if using_tool_fallback:
final_report = {
"status": "failed",
"explanation": f"兜底模型连续 {action_failure_attempts} 次设备动作失败,已停止任务。最后原因:{turn.result()}",
}
break
# A fresh, valid observation is required before spending any
# fallback model calls. Hardware recovery is not model retry.
(
shot_path,
current_pre_screenshot_bytes,
current_xml_list,
) = await capture_screenshot_and_parse_ui(self.ctx, state, self.controller)
state.latest_screenshot = shot_path
fallback_llm = get_llm(self.ctx, name="operator", use_fallback=True)
using_tool_fallback = True
self._llm = fallback_llm
action_failure_attempts = 0
no_tool_attempts = 0
pending_notices.append(
f"首选模型连续 {settings.ARTEMIS_ACTION_FAILURE_ATTEMPTS} 次设备动作失败。"
f"最后原因:{turn.result()}。已重新观察;本步骤由兜底模型重新定位,勿重复旧动作。"
)
ledger.stage_turn(messages[turn_base:])
previous_turn = turn
if final_report_from_calls is not None:
@@ -136,9 +136,14 @@ async def capture_screenshot_and_parse_ui(
Thin adapter over :func:`artemis.mcp.observation.observe` that adds the LangGraph
``State`` write-back (indexed points/elements) the agents rely on.
"""
obs, screenshot_bytes = await observe(ctx, controller, settle_ms=0 if skip_settling else 400)
if not obs.ok:
return None, None, None
from artemis.mcp.observation import require_observation
state.indexed_points = []
state.indexed_elements = []
state.latest_screenshot = None
obs, screenshot_bytes = await require_observation(
ctx, controller, settle_ms=0 if skip_settling else 400
)
if obs.hierarchy_ok:
state.indexed_points = [el["center"] for el in obs.elements]
+6
View File
@@ -122,6 +122,12 @@ class Settings(BaseSettings):
ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS: int = Field(default=3, ge=1, le=20)
# Consecutive Flash responses without any tool call before failing the task.
ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS: int = Field(default=3, ge=1, le=20)
ARTEMIS_TASK_TIMEOUT_SECONDS: int = Field(default=1800, ge=30, le=86400)
ARTEMIS_OBSERVATION_TIMEOUT_SECONDS: float = Field(default=60, gt=0, le=300)
ARTEMIS_OBSERVATION_MAX_ATTEMPTS: int = Field(default=3, ge=1, le=5)
ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS: float = Field(default=10, gt=0, le=60)
ARTEMIS_ACTION_FAILURE_ATTEMPTS: int = Field(default=3, ge=1, le=10)
ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS: float = Field(default=30, gt=0, le=300)
# Android ADB Connectivity
ADB_HOST: str | None = Field(default=DEFAULT_ADB_HOST)
+95 -28
View File
@@ -19,6 +19,7 @@ import base64
from io import BytesIO
from pathlib import Path
from typing import Any, Literal
from PIL import Image
from adbutils import AdbClient, AdbDevice
from artemis.clients.ui_automator_client import (
@@ -26,6 +27,7 @@ from artemis.clients.ui_automator_client import (
_parse_hierarchy_xml_to_elements,
)
from artemis.config.agent import load_agent_config
from artemis.config.settings import settings
from artemis.config.paths import get_temp_dir
from artemis.drivers.base import BaseDeviceDriver, KeyCode, ScreenData, SwipeDirection
from artemis.toolchain import find_ffmpeg, find_scrcpy
@@ -91,6 +93,7 @@ class AndroidAdbDriver(BaseDeviceDriver):
self._recording_process: asyncio.subprocess.Process | None = None
self._recording_output_path: Path | None = None
self._screenshot_config = load_agent_config().screenshot
self._screen_capture_task: asyncio.Task[ScreenData] | None = None
@property
def device_id(self) -> str:
@@ -122,6 +125,24 @@ class AndroidAdbDriver(BaseDeviceDriver):
await asyncio.to_thread(self._ui_adb_client.disconnect)
async def get_screen_data(self, skip_settling: bool = False) -> ScreenData:
"""Bound observation latency and share unfinished capture work across retries."""
task = self._screen_capture_task
if task is None or task.done():
task = asyncio.create_task(self._get_screen_data(skip_settling))
# A timed-out caller does not cancel a blocking SDK thread or start
# another concurrent read on the same device. Retrieve late errors.
task.add_done_callback(lambda done: None if done.cancelled() else done.exception())
self._screen_capture_task = task
try:
return await asyncio.wait_for(
asyncio.shield(task), timeout=settings.ARTEMIS_OBSERVATION_TIMEOUT_SECONDS
)
except TimeoutError as exc:
raise TimeoutError(
f"设备屏幕观测超时({settings.ARTEMIS_OBSERVATION_TIMEOUT_SECONDS:g} 秒)"
) from exc
async def _get_screen_data(self, skip_settling: bool = False) -> ScreenData:
"""Captures screenshot and XML hierarchy concurrently."""
if not skip_settling:
await asyncio.sleep(0.3)
@@ -131,11 +152,14 @@ class AndroidAdbDriver(BaseDeviceDriver):
screenshot_base64: str = ""
ui_hierarchy_xml: str | None = None
ui_elements: list[dict[str, Any]] = []
capture_errors: list[str] = []
# Check if ui_adb_client has direct screen data support
if self._ui_adb_client and hasattr(self._ui_adb_client, "get_screen_data"):
try:
raw_ui_data = self._ui_adb_client.get_screen_data()
raw_ui_data = await asyncio.to_thread(self._ui_adb_client.get_screen_data)
if asyncio.iscoroutine(raw_ui_data):
raw_ui_data = await raw_ui_data
if (
hasattr(raw_ui_data, "base64")
and isinstance(raw_ui_data.base64, str)
@@ -158,11 +182,18 @@ class AndroidAdbDriver(BaseDeviceDriver):
elif isinstance(raw_elements, list):
ui_elements = raw_elements
except Exception as e:
capture_errors.append(str(e))
logger.debug(f"Direct ui_adb_client.get_screen_data error: {e}")
if not screenshot_base64 or not isinstance(screenshot_base64, str):
try:
pil_img = await asyncio.to_thread(self.device.screenshot)
raw_image = await asyncio.to_thread(
self.device.shell,
["screencap", "-p"],
encoding=None,
timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS,
)
pil_img = Image.open(BytesIO(raw_image))
if hasattr(pil_img, "save"):
buf = BytesIO()
pil_img.save(buf, format="PNG")
@@ -170,18 +201,24 @@ class AndroidAdbDriver(BaseDeviceDriver):
self._width, self._height = pil_img.size
screenshot_base64 = base64.b64encode(screenshot_bytes).decode("utf-8")
except Exception as e:
capture_errors.append(str(e))
logger.warning(f"Device screenshot capture failed on {self._device_id}: {e}")
if not screenshot_base64 or not isinstance(screenshot_base64, str):
# Fallback for headless testing environments
screenshot_base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
screenshot_bytes = base64.b64decode(screenshot_base64)
raise RuntimeError(
"设备截图失败:ADB 与屏幕辅助服务均未返回有效截图;" + "; ".join(capture_errors)
)
with Image.open(BytesIO(screenshot_bytes)) as image:
if min(image.size) <= 1:
raise RuntimeError("设备截图无效:收到占位图,禁止基于旧画面继续操作")
image.verify()
# 2. Capture UI Hierarchy via UIAutomator
if self._ui_adb_client and not ui_elements:
try:
if hasattr(self._ui_adb_client, "get_hierarchy"):
res = self._ui_adb_client.get_hierarchy()
res = await asyncio.to_thread(self._ui_adb_client.get_hierarchy)
hierarchy = await res if asyncio.iscoroutine(res) else res
if isinstance(hierarchy, str):
ui_hierarchy_xml = hierarchy
@@ -189,7 +226,7 @@ class AndroidAdbDriver(BaseDeviceDriver):
else:
ui_elements = hierarchy
elif hasattr(self._ui_adb_client, "get_ui_elements"):
res = self._ui_adb_client.get_ui_elements()
res = await asyncio.to_thread(self._ui_adb_client.get_ui_elements)
ui_elements = await res if asyncio.iscoroutine(res) else res
except Exception as e:
logger.debug(f"UI hierarchy extraction failed: {e}")
@@ -208,16 +245,8 @@ class AndroidAdbDriver(BaseDeviceDriver):
screen_width=w,
screen_height=h,
)
clean_b64 = (
screenshot_base64
if isinstance(screenshot_base64, str) and screenshot_base64
else "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
)
clean_bytes = (
screenshot_bytes
if isinstance(screenshot_bytes, bytes) and screenshot_bytes
else base64.b64decode(clean_b64)
)
clean_b64 = screenshot_base64
clean_bytes = screenshot_bytes
if self._screenshot_config.enabled:
limited_bytes = limit_screenshot_long_edge(
clean_bytes, self._screenshot_config.max_long_edge
@@ -255,7 +284,9 @@ class AndroidAdbDriver(BaseDeviceDriver):
cmd = f" && sleep {delay_ms / 1000.0:.3f} && ".join(taps)
logger.info(f"[ADB] {cmd}")
await asyncio.to_thread(self.device.shell, cmd)
await asyncio.to_thread(
self.device.shell, cmd, timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS
)
return True
except Exception as e:
logger.error(f"Tap failed at ({x}, {y}): {e}")
@@ -275,7 +306,9 @@ class AndroidAdbDriver(BaseDeviceDriver):
try:
cmd = f"input swipe {start_x} {start_y} {end_x} {end_y} {duration_ms}"
logger.info(f"[ADB] {cmd}")
await asyncio.to_thread(self.device.shell, cmd)
await asyncio.to_thread(
self.device.shell, cmd, timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS
)
return True
except Exception as e:
logger.error(f"Swipe failed from ({start_x},{start_y}) to ({end_x},{end_y}): {e}")
@@ -324,7 +357,11 @@ class AndroidAdbDriver(BaseDeviceDriver):
"input keyevent 67 && "
"input keyevent 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67"
)
await asyncio.to_thread(self.device.shell, clear_cmd)
await asyncio.to_thread(
self.device.shell,
clear_cmd,
timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS,
)
else:
# Append mode: move cursor to the very end of existing text
await asyncio.to_thread(
@@ -346,7 +383,11 @@ class AndroidAdbDriver(BaseDeviceDriver):
set_clip_ok = True
if set_clip_ok:
await asyncio.to_thread(self.device.shell, "input keyevent 279")
await asyncio.to_thread(
self.device.shell,
"input keyevent 279",
timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS,
)
return True
except Exception as e:
logger.debug(f"Clipboard paste fallback to ADB input: {e}")
@@ -359,7 +400,11 @@ class AndroidAdbDriver(BaseDeviceDriver):
if "adbkeyboard" in str(default_ime).lower():
b64_text = base64.b64encode(norm_text.encode("utf-8")).decode("utf-8")
broadcast_cmd = f"am broadcast -a ADB_INPUT_B64 --es msg '{b64_text}'"
await asyncio.to_thread(self.device.shell, broadcast_cmd)
await asyncio.to_thread(
self.device.shell,
broadcast_cmd,
timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS,
)
return True
except Exception as e:
# ADBKeyboard probe/broadcast failed; fall through to native input.
@@ -370,10 +415,18 @@ class AndroidAdbDriver(BaseDeviceDriver):
for i, line in enumerate(lines):
if i > 0:
# Send Enter key between lines
await asyncio.to_thread(self.device.shell, "input keyevent 66")
await asyncio.to_thread(
self.device.shell,
"input keyevent 66",
timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS,
)
if line:
escaped = _escape_for_adb_text(line)
await asyncio.to_thread(self.device.shell, f"input text {escaped}")
await asyncio.to_thread(
self.device.shell,
f"input text {escaped}",
timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS,
)
return True
except Exception as e:
logger.error(f"Input text failed for '{text}': {e}")
@@ -386,7 +439,11 @@ class AndroidAdbDriver(BaseDeviceDriver):
key_name = str(key).lower().replace("keycode.", "")
keycode_val = ANDROID_KEYCODE_MAP.get(key_name, key)
await asyncio.to_thread(self.device.shell, f"input keyevent {keycode_val}")
await asyncio.to_thread(
self.device.shell,
f"input keyevent {keycode_val}",
timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS,
)
return True
except Exception as e:
logger.error(f"Press key failed for '{key}': {e}")
@@ -395,7 +452,9 @@ class AndroidAdbDriver(BaseDeviceDriver):
async def launch_app(self, package_name: str) -> bool:
try:
cmd = f"monkey -p {package_name} -c android.intent.category.LAUNCHER 1"
await asyncio.to_thread(self.device.shell, cmd)
await asyncio.to_thread(
self.device.shell, cmd, timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS
)
return True
except Exception as e:
logger.error(f"Launch app failed for '{package_name}': {e}")
@@ -403,7 +462,11 @@ class AndroidAdbDriver(BaseDeviceDriver):
async def stop_app(self, package_name: str) -> bool:
try:
await asyncio.to_thread(self.device.shell, f"am force-stop {package_name}")
await asyncio.to_thread(
self.device.shell,
f"am force-stop {package_name}",
timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS,
)
return True
except Exception as e:
logger.error(f"Stop app failed for '{package_name}': {e}")
@@ -439,7 +502,11 @@ class AndroidAdbDriver(BaseDeviceDriver):
async def execute_shell(self, command: str, timeout_seconds: float = 15.0) -> str:
try:
return await asyncio.wait_for(
asyncio.to_thread(self.device.shell, command),
asyncio.to_thread(
self.device.shell,
command,
timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS,
),
timeout=timeout_seconds,
)
except Exception as e:
+37 -2
View File
@@ -22,9 +22,12 @@ Validator -- which runs its own UI-change polling -- simply never calls it.
import asyncio
import base64
from io import BytesIO
import time
from PIL import Image
from artemis.config import get_temp_dir
from artemis.config.settings import settings
from artemis.context import ArtemisContext
from artemis.controllers.unified_controller import UnifiedMobileController
from artemis.mcp.action_types import ActionCode, ObserveResult
@@ -34,7 +37,29 @@ from artemis.utils.visualization import format_minimal_list_with_elements
logger = get_logger(__name__)
__all__ = ["observe"]
__all__ = ["observe", "require_observation", "DeviceObservationError"]
class DeviceObservationError(RuntimeError):
"""A live device could not be observed after bounded recovery."""
async def require_observation(
ctx: ArtemisContext, controller: UnifiedMobileController, settle_ms: int = 400
) -> tuple[ObserveResult, bytes | None]:
"""Recover observation without spending model calls on unavailable screens."""
last = "设备未返回有效画面"
for attempt in range(settings.ARTEMIS_OBSERVATION_MAX_ATTEMPTS):
observation, image = await observe(ctx, controller, settle_ms=settle_ms)
if observation.ok:
return observation, image
last = observation.message
logger.warning(
f"设备观测恢复 {attempt + 1}/{settings.ARTEMIS_OBSERVATION_MAX_ATTEMPTS}:{last}"
)
raise DeviceObservationError(
f"设备观测失败(已尝试 {settings.ARTEMIS_OBSERVATION_MAX_ATTEMPTS} 次):{last}"
)
async def observe(
@@ -60,9 +85,19 @@ async def observe(
await asyncio.sleep(settle_ms / 1000.0)
else:
logger.info("Retrieving screen data directly (skip settling)...")
device_data = await controller.get_screen_data()
device_data = await asyncio.wait_for(
controller.get_screen_data(), timeout=settings.ARTEMIS_OBSERVATION_TIMEOUT_SECONDS
)
screenshot_bytes = base64.b64decode(device_data.base64)
with Image.open(BytesIO(screenshot_bytes)) as image:
if min(image.size) <= 1:
raise ValueError("设备截图为占位图,当前画面不可用")
image.verify()
except Exception as e:
if isinstance(e, TimeoutError):
e = TimeoutError(
f"设备屏幕观测超时({settings.ARTEMIS_OBSERVATION_TIMEOUT_SECONDS:g} 秒)"
)
logger.error(f"Failed to capture screen state: {e}")
return (
ObserveResult(
+2
View File
@@ -221,6 +221,7 @@ def submit_task_to_daemon(
conversation_id: str | None = None,
verification_level: str | None = None,
explorer_mode: str | None = None,
runtime_overrides: dict[str, Any] | None = None,
base_url: str | None = None,
timeout: float = 15.0,
) -> dict[str, Any] | None:
@@ -246,6 +247,7 @@ def submit_task_to_daemon(
"session_id": session_id,
"ingress": ingress,
"conversation_id": conversation_id,
"runtime_overrides": runtime_overrides,
}
try:
+30
View File
@@ -0,0 +1,30 @@
"""Execution budgets shared by supervised web and MCP tasks."""
from dataclasses import dataclass, field
import time
from artemis.config.settings import settings
def task_timeout(item: dict, job: dict | None = None, batch: dict | None = None) -> int:
options = item.get("runtime_overrides") or (job or {}).get("case", {}).get("task_config") or {}
return int(
options.get("timeout_seconds")
or (batch or {}).get("timeout_seconds")
or settings.ARTEMIS_TASK_TIMEOUT_SECONDS
)
@dataclass
class ExecutionBudget:
limit: float
elapsed: float = 0
_last: float = field(default_factory=time.monotonic)
_excluded: bool = False
def update(self, *, excluded: bool = False) -> float:
now = time.monotonic()
if not self._excluded:
self.elapsed += max(0, now - self._last)
self._last, self._excluded = now, excluded
return max(0, self.limit - self.elapsed)
+64
View File
@@ -0,0 +1,64 @@
"""Persist useful failure evidence even when a worker must be terminated."""
import json
import os
from pathlib import Path
import uuid
from artemis.config import TRACES_PATH
def read_worker_outcome(session_id: str) -> dict:
"""Read a worker's terminal signal without waiting for hung SDK threads."""
try:
data = json.loads(
(TRACES_PATH / session_id / "run_outcome.json").read_text(encoding="utf-8")
)
if (
isinstance(data, dict)
and data.get("worker_finalized") is True
and data.get("task_status")
in {
"failed",
"completed",
"cancelled",
"interrupted",
}
):
return data
except (OSError, ValueError):
pass # Atomic outcome is absent until the task finalizes.
return {}
def save_failure_result(session_id: str, error: str, *, code: str = "execution_failed") -> None:
if not session_id or not all(c.isalnum() or c in "-_" for c in session_id):
raise ValueError("无效的任务编号")
directory = TRACES_PATH / session_id
directory.mkdir(parents=True, exist_ok=True)
payload: dict = {}
try:
previous = json.loads((directory / "result.json").read_text(encoding="utf-8"))
if isinstance(previous, dict):
payload.update(previous)
except (OSError, ValueError):
pass # Missing/truncated results are expected after a killed worker.
payload.update(
{
"status": "failed",
"task_status": "failed",
"explanation": error,
"error": error,
"code": code,
"worker_finalized": True,
"tests": payload.get("tests") or {},
}
)
for name in ("result.json", "run_outcome.json"):
target = directory / name
temporary = target.with_name(f".{name}.{uuid.uuid4().hex}.tmp")
try:
temporary.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
os.replace(temporary, target)
finally:
temporary.unlink(missing_ok=True)
+21
View File
@@ -32,6 +32,7 @@ import shutil
from shutil import which
import sys
import threading
import time
from types import NoneType
from typing import Any, TypeVar, overload
import uuid
@@ -573,6 +574,13 @@ class Agent:
)
try:
if device_lock is not None and os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
from artemis.runtime.task_control import control_dir, _write_atomic
if sess_id and os.environ.get("ARTEMIS_TASK_WORKER") == "1":
_write_atomic(
control_dir(str(sess_id)) / "task_control_state.json",
{"status": "queued", "updated_at": time.time(), "pid": os.getpid()},
)
queue_cancel_event = threading.Event()
acquire_task = asyncio.create_task(
asyncio.to_thread(
@@ -605,6 +613,7 @@ class Agent:
task_control = TaskControl(str(sess_id), device_lock)
context.task_control = task_control
task_control._state("running")
self._prepare_output_files(task=task)
if os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
if self._ui_adb_client is not None:
@@ -712,6 +721,14 @@ class Agent:
state=last_state_snapshot,
error=err,
)
from artemis.runtime.task_result import save_failure_result
if sess_id:
save_failure_result(
str(sess_id),
err,
code=flash_result.get("code", "execution_failed"),
)
if context.data_engine:
context.data_engine.end_session("failed")
return output
@@ -862,6 +879,10 @@ class Agent:
state=last_state_snapshot,
error=err,
)
from artemis.runtime.task_result import save_failure_result
if sess_id:
save_failure_result(str(sess_id), err)
if context.data_engine:
context.data_engine.end_session("failed")