Compare commits

...
2 Commits
22 changed files with 308 additions and 23 deletions

No files matched your search

+4
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
@@ -19,6 +21,8 @@ ARTEMIS_OUTPUTTER_ENABLED=true
ARTEMIS_STEP_SUMMARIZER_ENABLED=true ARTEMIS_STEP_SUMMARIZER_ENABLED=true
# Pro 连续无工具调用或参数校验失败的最大次数;耗尽后明确失败,避免空转。 # Pro 连续无工具调用或参数校验失败的最大次数;耗尽后明确失败,避免空转。
ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS=3 ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS=3
# Flash 连续无工具调用的最大次数;无效回复不会重复送回模型。
ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS=3
# 视频分段、重试与单次分析超时(秒) # 视频分段、重试与单次分析超时(秒)
ARTEMIS_VIDEO_CHUNK_SECONDS=60 ARTEMIS_VIDEO_CHUNK_SECONDS=60
@@ -130,6 +130,11 @@ async def tick_batches():
batch.update(status="paused", reason="模型请求已暂停,请先恢复模型连接再继续批次") batch.update(status="paused", reason="模型请求已暂停,请先恢复模型连接再继续批次")
store.put("batch", batch["id"], batch) store.put("batch", batch["id"], batch)
for job in active: for job in active:
# The queue records started_at at submission, then replaces it
# when the worker actually starts. Waiting for a device must
# not consume this case's execution budget.
if job["status"] != "running":
continue
limit = int( limit = int(
(job.get("case", {}).get("task_config") or {}).get("timeout_seconds") (job.get("case", {}).get("task_config") or {}).get("timeout_seconds")
or batch.get("timeout_seconds", 1800) or batch.get("timeout_seconds", 1800)
@@ -139,8 +144,7 @@ async def tick_batches():
if task_state.get("non_execution_started_at"): if task_state.get("non_execution_started_at"):
excluded += time.time() - float(task_state["non_execution_started_at"]) excluded += time.time() - float(task_state["non_execution_started_at"])
if ( if (
job["status"] != "finalizing" not job.get("timed_out")
and not job.get("timed_out")
and time.time() - job.get("started_at", time.time()) - excluded > limit and time.time() - job.get("started_at", time.time()) - excluded > limit
): ):
job.update( job.update(
@@ -148,8 +152,8 @@ async def tick_batches():
) )
store.save_job(job) store.save_job(job)
task_queue_service.stop_tasks(session_id=job["session_id"]) task_queue_service.stop_tasks(session_id=job["session_id"])
batch.update(status="paused", reason="任务超时,已暂停剩余任务;请确认设备状态") # on_terminal records the failed case. The next pending
store.put("batch", batch["id"], batch) # case then runs normally unless stop_on_failure is set.
continue continue
if batch["status"] != "running": if batch["status"] != "running":
continue continue
+29 -4
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.0", "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.0", "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.0", "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",
+17 -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,
) )
@@ -1063,6 +1064,8 @@ class FlashRunner:
current_xml_list = xml_list current_xml_list = xml_list
previous_turn: _TurnRecord | None = None previous_turn: _TurnRecord | None = None
pending_notices: list[str] = [] pending_notices: list[str] = []
no_tool_attempts = 0
no_tool_limit = settings.ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS
while limit is None or turns < limit: while limit is None or turns < limit:
control = getattr(self.ctx, "task_control", None) control = getattr(self.ctx, "task_control", None)
@@ -1198,11 +1201,24 @@ class FlashRunner:
if is_final: if is_final:
final_report = {"status": "failed", "explanation": raw_text} final_report = {"status": "failed", "explanation": raw_text}
break break
no_tool_attempts += 1
if no_tool_attempts >= no_tool_limit:
final_report = {
"status": "failed",
"explanation": (
f"模型连续 {no_tool_attempts} 次未调用工具,"
"已停止任务以避免空转和重复计费;未执行新的设备动作。"
),
}
break
pending_notices.append(_NO_TOOL_CALL_NOTICE) pending_notices.append(_NO_TOOL_CALL_NOTICE)
ledger.stage_turn(messages[turn_base:]) # Keep the observation and any injected user instruction, but
# do not feed the model its own unproductive prose on retry.
ledger.stage_turn(messages[turn_base:-1])
previous_turn = _TurnRecord() previous_turn = _TurnRecord()
continue continue
no_tool_attempts = 0
# Process tool calls # Process tool calls
turn = _TurnRecord() turn = _TurnRecord()
( (
+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.",
+2
View File
@@ -114,6 +114,8 @@ class Settings(BaseSettings):
ARTEMIS_STEP_SUMMARIZER_ENABLED: bool = True ARTEMIS_STEP_SUMMARIZER_ENABLED: bool = True
# Consecutive Pro responses without a valid tool call before failing the task. # Consecutive Pro responses without a valid tool call before failing the task.
ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS: int = Field(default=3, ge=1, le=20) 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)
# Android ADB Connectivity # Android ADB Connectivity
ADB_HOST: str | None = Field(default=DEFAULT_ADB_HOST) ADB_HOST: str | None = Field(default=DEFAULT_ADB_HOST)
+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
} }
}, },
+15 -4
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
else:
continue continue
if time.monotonic() - started_at >= self._MUTEX_TIMEOUT_SECONDS:
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
+3 -2
View File
@@ -95,8 +95,9 @@ 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.
if os.environ.get("ARTEMIS_TASK_WORKER") != "1":
ScreenAwakeLease(device_id).cleanup_unowned_references() 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
} }
}, },
+14
View File
@@ -0,0 +1,14 @@
# ARTEMIS-Eapil 1.2.1
## 修复
- 批量任务达到执行超时后,该用例记录为失败,不再自动暂停整批。当前用例结算后继续执行下一项;开启“失败即停止”的批次仍会跳过剩余项。排队等待不计入执行超时。
- Flash 连续收到无工具调用的回复时,最多尝试 3 次并给出明确失败原因,避免持续空转和重复计费。重试会保留设备观察与用户指导,但不把模型的无效回复送回上下文。可通过 `ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS` 调整上限。
模型请求中的相同缓存 token 数量通常是提示词前缀缓存命中,不能据此判断缓存造成循环。本版针对无工具调用的执行循环建立退出条件。
## 安装与验证
请下载附件 `artemis-eapil-1.2.1-source.zip`,解压后运行 `start.bat`;国内网络使用 `start-cn.bat`。附件包含精简 Git 仓库,可供后续启动脚本检查更新。Gitea 自动生成的源码 ZIP 不含 `.git`,请使用发布附件。升级后重启 ARTEMIS 服务、刷新网页并重新加载 AI 客户端的 MCP。
验证:Python 默认测试 2182 项通过、6 项跳过;Ruff、质量基线和核心类型检查通过。
+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.0" version = "1.2.2"
description = "Autonomous AI Assistant for Mobile Automation" description = "Autonomous AI Assistant for Mobile Automation"
authors = [ authors = [
+83 -1
View File
@@ -4,7 +4,7 @@ from io import BytesIO
import json import json
import sqlite3 import sqlite3
import time import time
from unittest.mock import AsyncMock from unittest.mock import AsyncMock, Mock
from uuid import UUID from uuid import UUID
import zipfile import zipfile
@@ -347,6 +347,61 @@ async def test_batch_timeout_stays_visible_in_job_report_and_history(client, iso
) )
@pytest.mark.asyncio
@pytest.mark.parametrize("stop_on_failure", [False, True])
async def test_timed_out_case_releases_next_batch_case(isolated, monkeypatch, stop_on_failure):
from apps.admin_console import server
from artemis.runtime.device_pool import DeviceStatus
batch = service.create_batch(payload(timeout_seconds=1, stop_on_failure=stop_on_failure))
service.batch_action(batch["id"], "resume")
first = isolated.jobs(batch["id"])[0]
isolated.save_job({**first, "status": "running", "started_at": time.time() - 2})
monkeypatch.setattr(
service, "state", type("State", (), {"is_paused": False, "active_runs": {}})()
)
stop = Mock(return_value=True)
enqueue = AsyncMock(return_value={"tasks": [{"device_serial": "test-device"}]})
monkeypatch.setattr(server.task_queue_service, "stop_tasks", stop)
monkeypatch.setattr(server.task_queue_service, "enqueue_tasks", enqueue)
monkeypatch.setattr(
"artemis.runtime.device_pool.try_list_devices_async",
AsyncMock(return_value=[DeviceStatus(serial="test-device", state="device")]),
)
monkeypatch.setattr(reports, "export_batch", Mock())
await service.tick_batches()
assert isolated.get("batch", batch["id"])["status"] == "running"
assert "超时" in isolated.job(first["session_id"])["error"]
stop.assert_called_once_with(session_id=first["session_id"])
await service.tick_batches()
stop.assert_called_once()
await service.on_terminal({"session_id": first["session_id"]}, "cancelled")
await service.tick_batches()
statuses = [job["status"] for job in isolated.jobs(batch["id"])]
assert statuses == (["failed", "skipped"] if stop_on_failure else ["failed", "queued"])
assert enqueue.await_count == (0 if stop_on_failure else 1)
@pytest.mark.asyncio
async def test_queued_case_does_not_consume_execution_timeout(isolated, monkeypatch):
from apps.admin_console import server
batch = service.create_batch(payload(timeout_seconds=1))
service.batch_action(batch["id"], "resume")
first = isolated.jobs(batch["id"])[0]
isolated.save_job({**first, "status": "queued", "started_at": time.time() - 30})
monkeypatch.setattr(service, "state", type("State", (), {"is_paused": False})())
stop = Mock()
monkeypatch.setattr(server.task_queue_service, "stop_tasks", stop)
await service.tick_batches()
stop.assert_not_called()
assert isolated.job(first["session_id"])["status"] == "queued"
assert isolated.get("batch", batch["id"])["status"] == "running"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_model_timeout_from_runner_log_reaches_failed_task_and_report(isolated): async def test_model_timeout_from_runner_log_reaches_failed_task_and_report(isolated):
job = isolated.jobs(service.create_batch(payload())["id"])[0] job = isolated.jobs(service.create_batch(payload())["id"])[0]
@@ -365,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]
@@ -636,6 +636,32 @@ async def test_run_no_tool_call_turn_is_nudged_not_terminated(mock_context):
and any(EXECUTION_RESULT_MARKER in b.get("text", "") for b in m.content) and any(EXECUTION_RESULT_MARKER in b.get("text", "") for b in m.content)
for m in turn2_messages for m in turn2_messages
) )
assert not any(
isinstance(m, AIMessage) and m.content == "thinking only" for m in turn2_messages
)
@pytest.mark.asyncio
@pytest.mark.parametrize("attempts", [1, 3])
async def test_flash_stops_repeated_no_tool_responses(mock_context, monkeypatch, attempts):
from artemis.config.settings import settings
monkeypatch.setattr(settings, "ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS", attempts)
responses = [AIMessage(content="I will click now.", tool_calls=[]) for _ in range(attempts)]
with (
patch("artemis.controllers.unified_controller.get_driver"),
patch(
"artemis.agents.flash.runner.capture_screenshot_and_parse_ui",
AsyncMock(return_value=_INITIAL_OBSERVATION),
),
):
runner = _make_runner(mock_context, responses, max_turns=0)
result = await runner.run(State(initial_goal="Open Settings"))
assert result["status"] == "failed"
assert f"连续 {attempts} 次未调用工具" in result["explanation"]
assert runner._invoke_model.await_count == attempts
runner.executor.execute.assert_not_awaited()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -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.0" version = "1.2.2"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "adbutils" }, { name = "adbutils" },