Compare commits

..
1 Commits
Author SHA1 Message Date
chenmingxuan e1e26772d9 fix: prevent false timeout failures and release 1.2.2 2026-09-28 19:11:16 +08:00
18 changed files with 189 additions and 22 deletions

No files matched your search

+2
View File
@@ -6,6 +6,8 @@ OPENAI_BASE_URL=https://api-n-cd.playones.com/v1
OPENAI_MODEL=mimo-v2.6-flash OPENAI_MODEL=mimo-v2.6-flash
# 单次模型 HTTP 请求超时(秒) # 单次模型 HTTP 请求超时(秒)
OPENAI_TIMEOUT_SECONDS=300 OPENAI_TIMEOUT_SECONDS=300
# 可选的后台步骤截图摘要单次超时,独立于上面的主模型请求超时。
ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS=25
# 失败后最多重试 5 次,由 Artemis 统一管理,SDK 不叠加重试。 # 失败后最多重试 5 次,由 Artemis 统一管理,SDK 不叠加重试。
OPENAI_MAX_RETRIES=5 OPENAI_MAX_RETRIES=5
OPENAI_RETRY_INTERVAL_SECONDS=10 OPENAI_RETRY_INTERVAL_SECONDS=10
+32 -7
View File
@@ -95,21 +95,46 @@ def failure_reason(directory: Path, status: str, recorded: str | None) -> str:
state = read_json(directory / "status.json", {}) state = read_json(directory / "status.json", {})
if isinstance(state, dict) and state.get("error"): if isinstance(state, dict) and state.get("error"):
return redact(state["error"]) return redact(state["error"])
log_lines: list[str] = []
for name in ("stdout.log", "stderr.log"): for name in ("stdout.log", "stderr.log"):
path = directory / name path = directory / name
try: try:
with path.open("rb") as stream: with path.open("rb") as stream:
stream.seek(0, 2) stream.seek(0, 2)
stream.seek(max(0, stream.tell() - 131072)) stream.seek(max(0, stream.tell() - 131072))
lines = stream.read().decode("utf-8", errors="replace").splitlines() log_lines.extend(stream.read().decode("utf-8", errors="replace").splitlines())
except OSError: except OSError:
continue continue
for line in reversed(lines): for line in reversed(log_lines):
match = re.search(r"Error running automation:\s*(.+)", line) match = re.search(r"Error running automation:\s*(.+)", line)
if match: if match:
return redact(match.group(1)) return redact(match.group(1))
if re.search(r"(?i)(?:timeout|timed out|超时|deadline exceeded)", line): for line in reversed(log_lines):
return redact("模型请求或任务执行超时:" + line.strip()) stripped = line.strip()
if stripped.startswith(("⚠", "ℹ", "WARNING", "INFO", "DEBUG")):
continue
if any(
name in line
for name in (
"VisualStepSummarizer:",
"StepMemoryService:",
"Could not clean unowned screen wake locks",
)
):
continue
exception_line = re.fullmatch(
r"(?:[A-Za-z_]\w*\.)*[A-Za-z_]\w*(?:Error|Exception|Timeout):\s*.*",
stripped,
)
if exception_line and not re.search(r"(?i)(?:timeout|timed out|超时)", stripped):
return redact(stripped)
if re.search(
r"(?i)(?:\b(?:TimeoutError|APITimeoutError|ReadTimeout|ConnectTimeout)\s*:|"
r"(?:模型请求|任务执行|model request|task execution).{0,100}"
r"(?:超时|timed out|deadline exceeded))",
stripped,
):
return redact("模型请求或任务执行超时:" + stripped)
return "任务未完成,运行日志中没有可提取的具体异常;请查看执行步骤。" return "任务未完成,运行日志中没有可提取的具体异常;请查看执行步骤。"
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "artemis-showcase-ui", "name": "artemis-showcase-ui",
"version": "1.2.1", "version": "1.2.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "artemis-showcase-ui", "name": "artemis-showcase-ui",
"version": "1.2.1", "version": "1.2.2",
"dependencies": { "dependencies": {
"@angular/cdk": "^22.1.5", "@angular/cdk": "^22.1.5",
"@angular/common": "^22.1.4", "@angular/common": "^22.1.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "artemis-showcase-ui", "name": "artemis-showcase-ui",
"version": "1.2.1", "version": "1.2.2",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",
"start": "ng serve --proxy-config proxy.conf.json", "start": "ng serve --proxy-config proxy.conf.json",
+1
View File
@@ -167,6 +167,7 @@ class FlashRunner:
ctx, ctx,
model_name=self.step_summarizer_cfg.model, model_name=self.step_summarizer_cfg.model,
retry_limit=self.memory_runtime_cfg.retry_limit, retry_limit=self.memory_runtime_cfg.retry_limit,
timeout_seconds=self.step_summarizer_cfg.timeout_seconds,
max_concurrency=self.memory_runtime_cfg.max_concurrency, max_concurrency=self.memory_runtime_cfg.max_concurrency,
flush_timeout_s=self.memory_runtime_cfg.flush_timeout_s, flush_timeout_s=self.memory_runtime_cfg.flush_timeout_s,
) )
+12 -2
View File
@@ -151,6 +151,7 @@ class VisualStepSummarizer(StepMemoryService):
ctx: ArtemisContext, ctx: ArtemisContext,
model_name: str | None = None, model_name: str | None = None,
retry_limit: int = 3, retry_limit: int = 3,
timeout_seconds: float = 25.0,
*, *,
max_concurrency: int = 1, max_concurrency: int = 1,
flush_timeout_s: float = 30.0, flush_timeout_s: float = 30.0,
@@ -161,6 +162,7 @@ class VisualStepSummarizer(StepMemoryService):
retry_limit=retry_limit, retry_limit=retry_limit,
flush_timeout_s=flush_timeout_s, flush_timeout_s=flush_timeout_s,
) )
self._timeout_seconds = timeout_seconds
# Initialize lightweight VLM: prioritize explicit model_name # Initialize lightweight VLM: prioritize explicit model_name
target_model = model_name or settings.OPENAI_MODEL target_model = model_name or settings.OPENAI_MODEL
@@ -459,7 +461,9 @@ class VisualStepSummarizer(StepMemoryService):
HumanMessage(content=content_blocks), HumanMessage(content=content_blocks),
] ]
response = await asyncio.wait_for(self._llm.ainvoke(messages), timeout=25.0) response = await asyncio.wait_for(
self._llm.ainvoke(messages), timeout=self._timeout_seconds
)
self._meter_lens_call(response) self._meter_lens_call(response)
summary_raw = response.content if isinstance(response.content, str) else "" summary_raw = response.content if isinstance(response.content, str) else ""
if isinstance(response.content, list): if isinstance(response.content, list):
@@ -505,8 +509,14 @@ class VisualStepSummarizer(StepMemoryService):
logger.debug(f"DataEngine step summary update skipped: {de_err}") logger.debug(f"DataEngine step summary update skipped: {de_err}")
return True return True
except TimeoutError:
logger.warning(
f"VisualStepSummarizer: Step {step_number} summary request timed out "
f"(limit {self._timeout_seconds:.1f}s)"
)
except Exception as e: except Exception as e:
logger.warning( logger.warning(
f"VisualStepSummarizer: Error generating summary for step {step_number}: {e}" f"VisualStepSummarizer: Error generating summary for step {step_number}: "
f"{type(e).__name__}: {e}"
) )
return False return False
+5
View File
@@ -370,6 +370,11 @@ class StepSummarizerConfig(BaseModel):
default_factory=lambda: settings.OPENAI_MODEL, default_factory=lambda: settings.OPENAI_MODEL,
description="Lightweight model used for background step state summarization.", description="Lightweight model used for background step state summarization.",
) )
timeout_seconds: float = Field(
default=25.0,
gt=0,
description="Timeout for one optional background visual-summary model request.",
)
prune_history_xml: bool = Field( prune_history_xml: bool = Field(
default=True, default=True,
description="Whether to prune outdated heavy UI XML trees from historical turns.", description="Whether to prune outdated heavy UI XML trees from historical turns.",
+1
View File
@@ -33,6 +33,7 @@
"step_summarizer": { "step_summarizer": {
"enabled": "${env:ARTEMIS_STEP_SUMMARIZER_ENABLED:-true}", "enabled": "${env:ARTEMIS_STEP_SUMMARIZER_ENABLED:-true}",
"model": "${env:OPENAI_MODEL:-mimo-v2.6-flash}", "model": "${env:OPENAI_MODEL:-mimo-v2.6-flash}",
"timeout_seconds": "${env:ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS:-25}",
"prune_history_xml": true "prune_history_xml": true
} }
}, },
+16 -5
View File
@@ -113,22 +113,28 @@ class ScreenAwakeLease:
} }
@contextmanager @contextmanager
def _mutex(self): def _mutex(self, *, wait_timeout_seconds: float | None = None):
self.mutex_path.parent.mkdir(parents=True, exist_ok=True) self.mutex_path.parent.mkdir(parents=True, exist_ok=True)
started_at = time.monotonic() started_at = time.monotonic()
timeout = (
self._MUTEX_TIMEOUT_SECONDS
if wait_timeout_seconds is None
else max(0.0, wait_timeout_seconds)
)
acquired = False acquired = False
while not acquired: while not acquired:
try: try:
fd = os.open(self.mutex_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY) fd = os.open(self.mutex_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
except FileExistsError: except FileExistsError:
if time.monotonic() - started_at >= timeout:
raise TimeoutError("Timed out acquiring the Artemis awake-lease mutex.")
if self._path_is_stale(self.mutex_path): if self._path_is_stale(self.mutex_path):
try: try:
self.mutex_path.unlink(missing_ok=True) self.mutex_path.unlink(missing_ok=True)
except OSError: except OSError:
pass pass
continue else:
if time.monotonic() - started_at >= self._MUTEX_TIMEOUT_SECONDS: continue
raise TimeoutError("Timed out acquiring the Artemis awake-lease mutex.")
time.sleep(self._MUTEX_POLL_SECONDS) time.sleep(self._MUTEX_POLL_SECONDS)
else: else:
try: try:
@@ -274,10 +280,15 @@ class ScreenAwakeLease:
def cleanup_unowned_references(self) -> bool: def cleanup_unowned_references(self) -> bool:
"""Drain legacy shell wake locks only when no live old host owns them.""" """Drain legacy shell wake locks only when no live old host owns them."""
try: try:
with self._mutex(): # Migration cleanup is optional. A busy owner must not delay every
# queued task by the normal 10-second lease-acquisition deadline.
with self._mutex(wait_timeout_seconds=1.0):
if self._live_leases(): if self._live_leases():
return False return False
return self._drain_references() return self._drain_references()
except TimeoutError:
logger.debug(f"Skipped optional wake-lock cleanup on {self.device_id}: mutex busy")
return False
except Exception as exc: except Exception as exc:
logger.warning(f"Could not clean unowned screen wake locks on {self.device_id}: {exc}") logger.warning(f"Could not clean unowned screen wake locks on {self.device_id}: {exc}")
return False return False
+4 -3
View File
@@ -95,9 +95,10 @@ def _configure_usb_stay_awake(device_id: str) -> str | None:
if not _awake_enabled() or os.environ.get("ARTEMIS_CLOUD_MODE") == "1": if not _awake_enabled() or os.environ.get("ARTEMIS_CLOUD_MODE") == "1":
return None return None
# Migration cleanup: previous Artemis versions used a persistent shell # Migration cleanup belongs to the long-lived host. A queued task worker
# wake lock. Drain it only after every live legacy owner has exited. # must not wait on another process's optional legacy wake-lock cleanup.
ScreenAwakeLease(device_id).cleanup_unowned_references() if os.environ.get("ARTEMIS_TASK_WORKER") != "1":
ScreenAwakeLease(device_id).cleanup_unowned_references()
commands = ( commands = (
( (
+1
View File
@@ -33,6 +33,7 @@
"step_summarizer": { "step_summarizer": {
"enabled": "${env:ARTEMIS_STEP_SUMMARIZER_ENABLED:-true}", "enabled": "${env:ARTEMIS_STEP_SUMMARIZER_ENABLED:-true}",
"model": "${env:OPENAI_MODEL:-mimo-v2.6-flash}", "model": "${env:OPENAI_MODEL:-mimo-v2.6-flash}",
"timeout_seconds": "${env:ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS:-25}",
"prune_history_xml": true "prune_history_xml": true
} }
}, },
+13
View File
@@ -0,0 +1,13 @@
# ARTEMIS-Eapil 1.2.2
## 修复
- 任务报告区分实际执行失败与可恢复的诊断警告。屏幕唤醒锁清理超时、步骤视觉摘要重试不再被误写为任务或模型请求超时;真实终止异常仍会显示。
- 旧屏幕唤醒锁清理遇到设备锁繁忙时快速跳过,避免队列任务因清理动作等待或连续失败。长驻服务负责后续清理。
- Flash 步骤视觉摘要使用独立超时,默认 25 秒,可通过 `ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS` 设置,不再占用主模型请求的 300 秒超时额度。
## 安装与验证
下载附件 `artemis-eapil-1.2.2-source.zip`,解压后运行 `start.bat`;国内网络使用 `start-cn.bat`。附件含精简 Git 仓库,后续可由启动脚本检查更新。升级后重启 ARTEMIS 服务、刷新网页并重新加载 AI 客户端的 MCP。
验证:Python 默认测试 2191 项通过、2 项跳过、8 项按标记排除;Ruff、质量基线和核心类型检查通过。两项真实模拟器 Flash 批量任务均完成;干净环境未复现原先的锁争用,锁繁忙与报告归因通过回归测试覆盖。
+1 -1
View File
@@ -14,7 +14,7 @@
[project] [project]
name = "artemis" name = "artemis"
version = "1.2.1" version = "1.2.2"
description = "Autonomous AI Assistant for Mobile Automation" description = "Autonomous AI Assistant for Mobile Automation"
authors = [ authors = [
@@ -420,6 +420,33 @@ async def test_model_timeout_from_runner_log_reaches_failed_task_and_report(isol
assert "超时" in reports.session_report(job["session_id"])["error"] assert "超时" in reports.session_report(job["session_id"])["error"]
def test_optional_timeout_warnings_are_not_reported_as_task_failure(tmp_path):
(tmp_path / "stdout.log").write_text(
"⚠ Could not clean unowned screen wake locks on emulator-5554: "
"Timed out acquiring the Artemis awake-lease mutex.\n"
"⚠ VisualStepSummarizer: Error generating summary for step 3: \n"
"ℹ StepMemoryService: Retrying Step 3 (attempt 4) after 1.0s\n",
encoding="utf-8",
)
assert reports.failure_reason(tmp_path, "failed", None) == (
"任务未完成,运行日志中没有可提取的具体异常;请查看执行步骤。"
)
(tmp_path / "stderr.log").write_text(
"TimeoutError: model request timed out after 300 seconds\n", encoding="utf-8"
)
reason = reports.failure_reason(tmp_path, "failed", None)
assert "model request timed out" in reason
assert "awake-lease" not in reason
(tmp_path / "stderr.log").write_text(
"DeviceBusyError: device queue reservation was cancelled\n", encoding="utf-8"
)
assert reports.failure_reason(tmp_path, "failed", None) == (
"DeviceBusyError: device queue reservation was cancelled"
)
def test_reports_do_not_execute_formulas_or_invent_pass(isolated): def test_reports_do_not_execute_formulas_or_invent_pass(isolated):
batch = service.create_batch(payload()) batch = service.create_batch(payload())
job = isolated.jobs(batch["id"])[0] job = isolated.jobs(batch["id"])[0]
@@ -14,6 +14,7 @@
"""Unit tests for VisualStepSummarizer and Context Compressor in Flash profile.""" """Unit tests for VisualStepSummarizer and Context Compressor in Flash profile."""
import asyncio
from unittest.mock import ANY, AsyncMock, Mock, call from unittest.mock import ANY, AsyncMock, Mock, call
from uuid import uuid4 from uuid import uuid4
import pytest import pytest
@@ -106,6 +107,29 @@ async def test_summarizer_retries_independently(mock_context):
assert attempt_count == 2 assert attempt_count == 2
@pytest.mark.asyncio
async def test_visual_summary_uses_its_own_configured_timeout(mock_context):
summarizer = VisualStepSummarizer(mock_context, retry_limit=0, timeout_seconds=0.01)
async def slow_response(_messages):
await asyncio.sleep(1)
summarizer._llm = Mock()
summarizer._llm.ainvoke = AsyncMock(side_effect=slow_response)
summarizer.dispatch(
step_number=1,
action_name="click",
action_args={"target": [100, 100]},
pre_img_bytes=b"pre",
post_img_bytes=b"post",
exec_outcome="Clicked",
)
await summarizer.flush()
assert summarizer.has_failed(1)
assert summarizer._llm.ainvoke.await_count == 1
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_summarizer_bounded_retry_marks_failed(mock_context): async def test_summarizer_bounded_retry_marks_failed(mock_context):
"""Persistent failures exit after 1 + retry_limit attempts with an explicit failed state.""" """Persistent failures exit after 1 + retry_limit attempts with an explicit failed state."""
+29
View File
@@ -13,6 +13,8 @@
# limitations under the License. # limitations under the License.
import json import json
from pathlib import Path
import time
import pytest import pytest
@@ -126,3 +128,30 @@ def test_migration_cleanup_never_drains_a_live_legacy_owner(monkeypatch):
cleanup._drain_references = lambda: pytest.fail("must not drain a live owner") cleanup._drain_references = lambda: pytest.fail("must not drain a live owner")
assert cleanup.cleanup_unowned_references() is False assert cleanup.cleanup_unowned_references() is False
def test_optional_cleanup_skips_a_busy_mutex_without_delaying_task_start(monkeypatch):
owner = ScreenAwakeLease("device-123")
owner.mutex_path.write_text(json.dumps(owner._payload()), encoding="utf-8")
cleanup = ScreenAwakeLease("device-123")
monkeypatch.setattr(cleanup, "_drain_references", lambda: pytest.fail("must not drain"))
started_at = time.monotonic()
assert cleanup.cleanup_unowned_references() is False
assert time.monotonic() - started_at < 2.0
def test_unremovable_stale_mutex_still_obeys_deadline(monkeypatch):
lease = ScreenAwakeLease("device-123")
lease.mutex_path.write_text(json.dumps({"pid": 999999}), encoding="utf-8")
original_unlink = Path.unlink
def refuse_mutex_unlink(path, *args, **kwargs):
if path == lease.mutex_path:
raise PermissionError("file is in use")
return original_unlink(path, *args, **kwargs)
monkeypatch.setattr(Path, "unlink", refuse_mutex_unlink)
with pytest.raises(TimeoutError, match="awake-lease mutex"):
with lease._mutex(wait_timeout_seconds=0.1):
pytest.fail("stale mutex must not be acquired")
+17
View File
@@ -1,3 +1,4 @@
import os
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from artemis.runtime.awake_service import ( from artemis.runtime.awake_service import (
@@ -37,6 +38,22 @@ def test_usb_policy_is_primary_when_android_reports_it_active(mock_run, lease_ty
assert not any("KEYCODE_UNKNOWN" in command for command in commands) assert not any("KEYCODE_UNKNOWN" in command for command in commands)
@patch.dict(os.environ, {"ARTEMIS_TASK_WORKER": "1"})
@patch("artemis.runtime.awake_service.ScreenAwakeLease")
@patch("artemis.runtime.awake_service.subprocess.run")
def test_queued_worker_does_not_run_optional_legacy_cleanup(mock_run, lease_type):
mock_run.side_effect = [
completed(),
completed(),
completed(),
completed("2\n"),
completed(" mStayOn=true\n"),
]
assert _configure_usb_stay_awake("device-123") == AWAKE_STRATEGY_USB
lease_type.assert_not_called()
@patch("artemis.runtime.awake_service.ScreenAwakeLease") @patch("artemis.runtime.awake_service.ScreenAwakeLease")
@patch("artemis.runtime.awake_service.subprocess.run") @patch("artemis.runtime.awake_service.subprocess.run")
def test_inactive_usb_policy_falls_back_to_effective_host_heartbeat(mock_run, _lease_type): def test_inactive_usb_policy_falls_back_to_effective_host_heartbeat(mock_run, _lease_type):
Generated
+1 -1
View File
@@ -206,7 +206,7 @@ wheels = [
[[package]] [[package]]
name = "artemis" name = "artemis"
version = "1.2.1" version = "1.2.2"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "adbutils" }, { name = "adbutils" },