Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1e26772d9 | ||
|
|
1dc2b0c686 |
No files matched your search
@@ -6,6 +6,8 @@ OPENAI_BASE_URL=https://api-n-cd.playones.com/v1
|
||||
OPENAI_MODEL=mimo-v2.6-flash
|
||||
# 单次模型 HTTP 请求超时(秒)
|
||||
OPENAI_TIMEOUT_SECONDS=300
|
||||
# 可选的后台步骤截图摘要单次超时,独立于上面的主模型请求超时。
|
||||
ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS=25
|
||||
# 失败后最多重试 5 次,由 Artemis 统一管理,SDK 不叠加重试。
|
||||
OPENAI_MAX_RETRIES=5
|
||||
OPENAI_RETRY_INTERVAL_SECONDS=10
|
||||
@@ -19,6 +21,8 @@ ARTEMIS_OUTPUTTER_ENABLED=true
|
||||
ARTEMIS_STEP_SUMMARIZER_ENABLED=true
|
||||
# Pro 连续无工具调用或参数校验失败的最大次数;耗尽后明确失败,避免空转。
|
||||
ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS=3
|
||||
# Flash 连续无工具调用的最大次数;无效回复不会重复送回模型。
|
||||
ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS=3
|
||||
|
||||
# 视频分段、重试与单次分析超时(秒)
|
||||
ARTEMIS_VIDEO_CHUNK_SECONDS=60
|
||||
|
||||
@@ -130,6 +130,11 @@ async def tick_batches():
|
||||
batch.update(status="paused", reason="模型请求已暂停,请先恢复模型连接再继续批次")
|
||||
store.put("batch", batch["id"], batch)
|
||||
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(
|
||||
(job.get("case", {}).get("task_config") or {}).get("timeout_seconds")
|
||||
or batch.get("timeout_seconds", 1800)
|
||||
@@ -139,8 +144,7 @@ async def tick_batches():
|
||||
if task_state.get("non_execution_started_at"):
|
||||
excluded += time.time() - float(task_state["non_execution_started_at"])
|
||||
if (
|
||||
job["status"] != "finalizing"
|
||||
and not job.get("timed_out")
|
||||
not job.get("timed_out")
|
||||
and time.time() - job.get("started_at", time.time()) - excluded > limit
|
||||
):
|
||||
job.update(
|
||||
@@ -148,8 +152,8 @@ async def tick_batches():
|
||||
)
|
||||
store.save_job(job)
|
||||
task_queue_service.stop_tasks(session_id=job["session_id"])
|
||||
batch.update(status="paused", reason="任务超时,已暂停剩余任务;请确认设备状态")
|
||||
store.put("batch", batch["id"], batch)
|
||||
# on_terminal records the failed case. The next pending
|
||||
# case then runs normally unless stop_on_failure is set.
|
||||
continue
|
||||
if batch["status"] != "running":
|
||||
continue
|
||||
|
||||
@@ -95,21 +95,46 @@ def failure_reason(directory: Path, status: str, recorded: str | None) -> str:
|
||||
state = read_json(directory / "status.json", {})
|
||||
if isinstance(state, dict) and state.get("error"):
|
||||
return redact(state["error"])
|
||||
log_lines: list[str] = []
|
||||
for name in ("stdout.log", "stderr.log"):
|
||||
path = directory / name
|
||||
try:
|
||||
with path.open("rb") as stream:
|
||||
stream.seek(0, 2)
|
||||
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:
|
||||
continue
|
||||
for line in reversed(lines):
|
||||
match = re.search(r"Error running automation:\s*(.+)", line)
|
||||
if match:
|
||||
return redact(match.group(1))
|
||||
if re.search(r"(?i)(?:timeout|timed out|超时|deadline exceeded)", line):
|
||||
return redact("模型请求或任务执行超时:" + line.strip())
|
||||
for line in reversed(log_lines):
|
||||
match = re.search(r"Error running automation:\s*(.+)", line)
|
||||
if match:
|
||||
return redact(match.group(1))
|
||||
for line in reversed(log_lines):
|
||||
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 "任务未完成,运行日志中没有可提取的具体异常;请查看执行步骤。"
|
||||
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "artemis-showcase-ui",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "artemis-showcase-ui",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.2",
|
||||
"dependencies": {
|
||||
"@angular/cdk": "^22.1.5",
|
||||
"@angular/common": "^22.1.4",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "artemis-showcase-ui",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.2",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve --proxy-config proxy.conf.json",
|
||||
|
||||
@@ -167,6 +167,7 @@ class FlashRunner:
|
||||
ctx,
|
||||
model_name=self.step_summarizer_cfg.model,
|
||||
retry_limit=self.memory_runtime_cfg.retry_limit,
|
||||
timeout_seconds=self.step_summarizer_cfg.timeout_seconds,
|
||||
max_concurrency=self.memory_runtime_cfg.max_concurrency,
|
||||
flush_timeout_s=self.memory_runtime_cfg.flush_timeout_s,
|
||||
)
|
||||
@@ -1063,6 +1064,8 @@ class FlashRunner:
|
||||
current_xml_list = xml_list
|
||||
previous_turn: _TurnRecord | None = None
|
||||
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:
|
||||
control = getattr(self.ctx, "task_control", None)
|
||||
@@ -1198,11 +1201,24 @@ class FlashRunner:
|
||||
if is_final:
|
||||
final_report = {"status": "failed", "explanation": raw_text}
|
||||
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)
|
||||
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()
|
||||
continue
|
||||
|
||||
no_tool_attempts = 0
|
||||
# Process tool calls
|
||||
turn = _TurnRecord()
|
||||
(
|
||||
|
||||
@@ -151,6 +151,7 @@ class VisualStepSummarizer(StepMemoryService):
|
||||
ctx: ArtemisContext,
|
||||
model_name: str | None = None,
|
||||
retry_limit: int = 3,
|
||||
timeout_seconds: float = 25.0,
|
||||
*,
|
||||
max_concurrency: int = 1,
|
||||
flush_timeout_s: float = 30.0,
|
||||
@@ -161,6 +162,7 @@ class VisualStepSummarizer(StepMemoryService):
|
||||
retry_limit=retry_limit,
|
||||
flush_timeout_s=flush_timeout_s,
|
||||
)
|
||||
self._timeout_seconds = timeout_seconds
|
||||
|
||||
# Initialize lightweight VLM: prioritize explicit model_name
|
||||
target_model = model_name or settings.OPENAI_MODEL
|
||||
@@ -459,7 +461,9 @@ class VisualStepSummarizer(StepMemoryService):
|
||||
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)
|
||||
summary_raw = response.content if isinstance(response.content, str) else ""
|
||||
if isinstance(response.content, list):
|
||||
@@ -505,8 +509,14 @@ class VisualStepSummarizer(StepMemoryService):
|
||||
logger.debug(f"DataEngine step summary update skipped: {de_err}")
|
||||
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:
|
||||
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
|
||||
@@ -370,6 +370,11 @@ class StepSummarizerConfig(BaseModel):
|
||||
default_factory=lambda: settings.OPENAI_MODEL,
|
||||
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(
|
||||
default=True,
|
||||
description="Whether to prune outdated heavy UI XML trees from historical turns.",
|
||||
|
||||
@@ -114,6 +114,8 @@ class Settings(BaseSettings):
|
||||
ARTEMIS_STEP_SUMMARIZER_ENABLED: bool = True
|
||||
# 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)
|
||||
# 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
|
||||
ADB_HOST: str | None = Field(default=DEFAULT_ADB_HOST)
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"step_summarizer": {
|
||||
"enabled": "${env:ARTEMIS_STEP_SUMMARIZER_ENABLED:-true}",
|
||||
"model": "${env:OPENAI_MODEL:-mimo-v2.6-flash}",
|
||||
"timeout_seconds": "${env:ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS:-25}",
|
||||
"prune_history_xml": true
|
||||
}
|
||||
},
|
||||
|
||||
@@ -113,22 +113,28 @@ class ScreenAwakeLease:
|
||||
}
|
||||
|
||||
@contextmanager
|
||||
def _mutex(self):
|
||||
def _mutex(self, *, wait_timeout_seconds: float | None = None):
|
||||
self.mutex_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
started_at = time.monotonic()
|
||||
timeout = (
|
||||
self._MUTEX_TIMEOUT_SECONDS
|
||||
if wait_timeout_seconds is None
|
||||
else max(0.0, wait_timeout_seconds)
|
||||
)
|
||||
acquired = False
|
||||
while not acquired:
|
||||
try:
|
||||
fd = os.open(self.mutex_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||
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):
|
||||
try:
|
||||
self.mutex_path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
continue
|
||||
if time.monotonic() - started_at >= self._MUTEX_TIMEOUT_SECONDS:
|
||||
raise TimeoutError("Timed out acquiring the Artemis awake-lease mutex.")
|
||||
else:
|
||||
continue
|
||||
time.sleep(self._MUTEX_POLL_SECONDS)
|
||||
else:
|
||||
try:
|
||||
@@ -274,10 +280,15 @@ class ScreenAwakeLease:
|
||||
def cleanup_unowned_references(self) -> bool:
|
||||
"""Drain legacy shell wake locks only when no live old host owns them."""
|
||||
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():
|
||||
return False
|
||||
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:
|
||||
logger.warning(f"Could not clean unowned screen wake locks on {self.device_id}: {exc}")
|
||||
return False
|
||||
@@ -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":
|
||||
return None
|
||||
|
||||
# Migration cleanup: previous Artemis versions used a persistent shell
|
||||
# wake lock. Drain it only after every live legacy owner has exited.
|
||||
ScreenAwakeLease(device_id).cleanup_unowned_references()
|
||||
# Migration cleanup belongs to the long-lived host. A queued task worker
|
||||
# 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()
|
||||
|
||||
commands = (
|
||||
(
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"step_summarizer": {
|
||||
"enabled": "${env:ARTEMIS_STEP_SUMMARIZER_ENABLED:-true}",
|
||||
"model": "${env:OPENAI_MODEL:-mimo-v2.6-flash}",
|
||||
"timeout_seconds": "${env:ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS:-25}",
|
||||
"prune_history_xml": true
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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、质量基线和核心类型检查通过。
|
||||
@@ -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
@@ -14,7 +14,7 @@
|
||||
|
||||
[project]
|
||||
name = "artemis"
|
||||
version = "1.2.0"
|
||||
version = "1.2.2"
|
||||
description = "Autonomous AI Assistant for Mobile Automation"
|
||||
|
||||
authors = [
|
||||
|
||||
@@ -4,7 +4,7 @@ from io import BytesIO
|
||||
import json
|
||||
import sqlite3
|
||||
import time
|
||||
from unittest.mock import AsyncMock
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
from uuid import UUID
|
||||
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
|
||||
async def test_model_timeout_from_runner_log_reaches_failed_task_and_report(isolated):
|
||||
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"]
|
||||
|
||||
|
||||
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):
|
||||
batch = service.create_batch(payload())
|
||||
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)
|
||||
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."""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import ANY, AsyncMock, Mock, call
|
||||
from uuid import uuid4
|
||||
import pytest
|
||||
@@ -106,6 +107,29 @@ async def test_summarizer_retries_independently(mock_context):
|
||||
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
|
||||
async def test_summarizer_bounded_retry_marks_failed(mock_context):
|
||||
"""Persistent failures exit after 1 + retry_limit attempts with an explicit failed state."""
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
# limitations under the License.
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import time
|
||||
|
||||
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")
|
||||
|
||||
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")
|
||||
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@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.subprocess.run")
|
||||
def test_inactive_usb_policy_falls_back_to_effective_host_heartbeat(mock_run, _lease_type):
|
||||
|
||||
Reference in new issue
Block a user