Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f826bdf3b0 | ||
|
|
c96935b47b | ||
|
|
18d17da966 | ||
|
|
0355af7833 | ||
|
|
079d7b1e28 | ||
|
|
1fdf7c0d1d | ||
|
|
606ed94160 | ||
|
|
8dbe054a8c | ||
|
|
868f611c36 | ||
|
|
d31eb2a19d | ||
|
|
ab066733b8 |
No files matched your search
+19
-1
@@ -11,6 +11,11 @@ ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS=120
|
||||
# 失败后最多重试 5 次,由 Artemis 统一管理,SDK 不叠加重试。
|
||||
OPENAI_MAX_RETRIES=5
|
||||
OPENAI_RETRY_INTERVAL_SECONDS=10
|
||||
OPENAI_RATE_LIMIT_RETRY_SECONDS=60
|
||||
OPENAI_TOOL_FALLBACK_MODEL=gpt-6-luna
|
||||
OPENAI_TOOL_FALLBACK_REASONING_EFFORT=high
|
||||
OPENAI_TOOL_FALLBACK_MAX_RETRIES=3
|
||||
ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS=3
|
||||
# default 表示不发送思考强度参数;接口不支持所选值时明确报错。
|
||||
OPENAI_REASONING_EFFORT=high
|
||||
|
||||
@@ -19,10 +24,22 @@ ARTEMIS_VIDEO_ENABLED=false
|
||||
ARTEMIS_RECORDING_ENABLED=false
|
||||
ARTEMIS_OUTPUTTER_ENABLED=true
|
||||
ARTEMIS_STEP_SUMMARIZER_ENABLED=true
|
||||
# 默认将任务截图长边压至 2048;false 保留设备原始截图。长边值可在 config/artemis.jsonc 的 agent.screenshot 中覆盖。
|
||||
ARTEMIS_SCREENSHOT_LIMIT_ENABLED=true
|
||||
# Pro 连续无工具调用或参数校验失败的最大次数;耗尽后明确失败,避免空转。
|
||||
ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS=3
|
||||
# Flash 连续无工具调用的最大次数;无效回复不会重复送回模型。
|
||||
ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS=3
|
||||
# 所有网页/MCP 任务的总执行时限;排队、用户暂停不计入。任务 JSON 的 timeout_seconds 可覆盖。
|
||||
ARTEMIS_TASK_TIMEOUT_SECONDS=1800
|
||||
# 单次屏幕观测预算、最多恢复次数和备用 ADB 命令时限。
|
||||
ARTEMIS_OBSERVATION_TIMEOUT_SECONDS=60
|
||||
ARTEMIS_OBSERVATION_MAX_ATTEMPTS=3
|
||||
ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS=10
|
||||
# Flash 连续设备动作失败后,重新观察并切换兜底模型;兜底仍失败时结束。
|
||||
ARTEMIS_ACTION_FAILURE_ATTEMPTS=3
|
||||
# 已写出任务结果后,后台线程/录屏清理最多等待多久;超时回收进程释放队列。
|
||||
ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS=30
|
||||
|
||||
# 视频分段、重试与单次分析超时(秒)
|
||||
ARTEMIS_VIDEO_CHUNK_SECONDS=60
|
||||
@@ -33,6 +50,8 @@ ARTEMIS_CHECKPOINT_TIMEOUT_SECONDS=180
|
||||
ARTEMIS_SETTLEMENT_TIMEOUT_SECONDS=120
|
||||
ARTEMIS_CREDENTIAL_TIMEOUT_SECONDS=12
|
||||
ARTEMIS_MODEL_CACHE_TTL_SECONDS=10
|
||||
# 守护服务轻量探测超时;探测超时且端口仍占用时,不重复启动服务。
|
||||
ARTEMIS_DAEMON_PROBE_TIMEOUT_SECONDS=2
|
||||
# 模型重试耗尽后的暂停等待上限(秒);0 表示无限等待
|
||||
LLM_PAUSE_TIMEOUT_SECONDS=900
|
||||
|
||||
@@ -49,6 +68,5 @@ ARTEMIS_HELPER_AUTO_INSTALL=true
|
||||
ARTEMIS_DEFAULT_PROFILE=flash
|
||||
|
||||
# 批量任务与 Excel 导入限制
|
||||
ARTEMIS_BATCH_TASK_TIMEOUT=1800
|
||||
ARTEMIS_IMPORT_MAX_ROWS=1000
|
||||
ARTEMIS_IMPORT_MAX_BYTES=10485760
|
||||
@@ -0,0 +1,2 @@
|
||||
# cmd.exe requires CRLF for batch scripts, especially UTF-8 Chinese text.
|
||||
*.bat text eol=crlf
|
||||
@@ -29,7 +29,7 @@ cd Artemis-Eapil
|
||||
常用选项:
|
||||
|
||||
```powershell
|
||||
.\start-cn.bat -SetupOnly # 准备环境并安装/更新已检测客户端的 MCP
|
||||
.\start-cn.bat -SetupOnly # 准备环境并安装/更新 Codex MCP
|
||||
.\start.bat -Port 8001 # 指定端口
|
||||
.\start.bat -SkipUpdate # 跳过 Git 源码更新检查
|
||||
.\start.bat -SkipMcpSync # MCP 配置异常时,仅启动网页
|
||||
@@ -48,21 +48,23 @@ cd Artemis-Eapil
|
||||
3. 输入任务目标,选择 Flash 或 Pro 后执行;在工作区查看步骤、截图和结果。
|
||||
4. 通过“报告与批量”导出报告或导入 Excel 用例;任务队列支持历史搜索。
|
||||
|
||||
预设模型为 `gpt-5.6-luna`、`mimo-v2.6-flash`,也可填写自定义名称。默认模型是 `mimo-v2.6-flash`,思考强度默认 `high`(最高可选 `max`),单次请求超时 **300 秒**,失败后最多重试 **5 次**,间隔 **10 秒**;视频分析与录屏默认关闭。模型及思考参数是否可用取决于所配置网关;不支持时会明确报错。
|
||||
预设模型为 `gpt-5.6-luna`、`gpt-6-luna`、`mimo-v2.6-flash`、`mimo-v2.6-pro`,也可填写自定义名称。默认模型是 `mimo-v2.6-flash`,思考强度默认 `high`(最高可选 `max`),单次请求超时 **300 秒**,失败后最多重试 **5 次**,间隔 **10 秒**。遇到 429 请求过多时改为每次等待 **60 秒**。连续 3 次没有有效工具调用时,本步骤改用 `gpt-6-luna`(high),兜底模型最多再尝试 3 次;下一步恢复首选模型。视频分析与录屏默认关闭。模型及思考参数是否可用取决于所配置网关;不支持时会明确报错。
|
||||
|
||||
也可在项目 `.env` 中设置 `OPENAI_API_KEY`、`OPENAI_BASE_URL` 和 `OPENAI_MODEL`,其他参数见 [.env.example](.env.example)。新安装默认接口地址为 `https://api-n-cd.playones.com/v1`;已保存的地址不变。直接编辑环境文件后需重启相应服务。不要提交自己的密钥。
|
||||
|
||||
“功能开关”默认将任务截图长边限制为 2048 像素;关闭后保留设备原始分辨率。可在 [config/artemis.jsonc](config/artemis.jsonc) 的 `agent.screenshot` 中覆盖 `enabled` 和 `max_long_edge`,重启后对新任务生效。
|
||||
|
||||
报告会区分“执行完成”和“验证通过”;没有验证证据的结果标为“未验证”。任务步骤与截图可查看,设备动作回放不提供。
|
||||
|
||||
## 让 AI 使用
|
||||
|
||||
Windows 启动脚本会为已检测到的 AI 客户端安装或更新 MCP。未被检测到的客户端,可手动安装,例如 Codex:
|
||||
目前仅支持为 Codex 配置 MCP。Windows 启动脚本检测 Codex 配置目录并安装或更新 ARTEMIS;也可手动安装:
|
||||
|
||||
```sh
|
||||
uv run artemis mcp --install codex
|
||||
```
|
||||
|
||||
安装或更新后,在客户端重新加载 MCP 或重启客户端,并按 [AI 接入指南](docs/ai-automation.md) 安装仓库中的 [artemis-automation Skill](skills/artemis-automation/SKILL.md)。Skill 不会随启动脚本自动安装到用户全局目录。其他客户端可通过 `uv run artemis mcp --help` 查看接入方式。
|
||||
安装或更新后,在 Codex 中重新加载 MCP 或重启 Codex,并按 [AI 接入指南](docs/ai-automation.md) 安装仓库中的 [artemis-automation Skill](skills/artemis-automation/SKILL.md)。Skill 不会随启动脚本自动安装到用户全局目录。可用 `uv run artemis mcp --generate-config codex` 查看配置模板。
|
||||
|
||||
示例指令:
|
||||
|
||||
@@ -70,7 +72,7 @@ uv run artemis mcp --install codex
|
||||
|
||||
## 更新与排错
|
||||
|
||||
Windows 用户再次运行一键脚本时,会先检查 Git 上游;即使 `start.bat` 或 `start-cn.bat` 本身被更新,也会重新启动新版脚本,再检查项目依赖、前端构建并同步已检测客户端的 MCP。源码只做快进更新。本地修改、分支分叉或网络认证失败时保留本地版本;有任务执行或排队时停止更新启动。MCP 配置同步失败会明确停止启动,可使用 `-SkipMcpSync` 仅启动网页。更新后需在 AI 客户端重新加载 MCP。
|
||||
Windows 用户再次运行一键脚本时,会先检查 Git 上游;即使 `start.bat` 或 `start-cn.bat` 本身被更新,也会重新启动新版脚本,再检查项目依赖、前端构建并同步 Codex MCP。源码只做快进更新。本地修改、分支分叉或网络认证失败时保留本地版本;有任务执行或排队时停止更新启动。Codex 配置同步失败会明确停止启动,可使用 `-SkipMcpSync` 仅启动网页。更新后需在 Codex 中重新加载 MCP。
|
||||
|
||||
1.1.1 及更新版本的发布 ZIP 含浅层 Git 工作树,解压后再次运行一键脚本即可检查更新。1.1.0 及更早 ZIP 不含 Git 信息,无法自行拉取;需要下载新版源码包重新解压一次,之后即可自动更新。遇到启动或设备问题,在项目目录运行 `uv run artemis doctor`。
|
||||
|
||||
|
||||
@@ -10,7 +10,9 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from apps.admin_console.services.automation_store import identifier, store
|
||||
from apps.admin_console.services import automation_service as service
|
||||
from apps.admin_console.services import batch_archive as archive_service
|
||||
from apps.admin_console.services import report_export, task_import
|
||||
from artemis.config.settings import settings
|
||||
|
||||
router = APIRouter(prefix="/api/automation", tags=["automation"])
|
||||
|
||||
@@ -29,7 +31,7 @@ class BatchRequest(ReportPreferences):
|
||||
profile: Literal["flash", "pro"] = "flash"
|
||||
stop_on_failure: bool = False
|
||||
timeout_seconds: int = Field(
|
||||
default_factory=lambda: int(os.getenv("ARTEMIS_BATCH_TASK_TIMEOUT", "1800")),
|
||||
default_factory=lambda: settings.ARTEMIS_TASK_TIMEOUT_SECONDS,
|
||||
ge=30,
|
||||
le=86400,
|
||||
)
|
||||
@@ -49,7 +51,7 @@ def require(kind, key):
|
||||
def preferences():
|
||||
return {
|
||||
**store.preferences(),
|
||||
"batch_timeout_seconds": int(os.getenv("ARTEMIS_BATCH_TASK_TIMEOUT", "1800")),
|
||||
"batch_timeout_seconds": settings.ARTEMIS_TASK_TIMEOUT_SECONDS,
|
||||
}
|
||||
|
||||
|
||||
@@ -128,10 +130,13 @@ def delete_batch(batch_id: str):
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
try:
|
||||
if archive_service.status(key, require("batch", key))["status"] == "preparing":
|
||||
raise HTTPException(409, "报告正在打包,请完成后再删除批次")
|
||||
if not service.delete_batch(key):
|
||||
raise HTTPException(404, "批次不存在")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(409, str(exc)) from exc
|
||||
archive_service.forget(key)
|
||||
return {"batch_id": key, "status": "deleted"}
|
||||
|
||||
|
||||
@@ -144,10 +149,30 @@ def batch_action(batch_id: str, action: Literal["pause", "resume", "retry_failed
|
||||
raise HTTPException(409, str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/batches/{batch_id}/archive/prepare")
|
||||
def prepare_batch_archive(batch_id: str):
|
||||
batch = require("batch", batch_id)
|
||||
return archive_service.prepare(batch_id, batch)
|
||||
|
||||
|
||||
@router.get("/batches/{batch_id}/archive/status")
|
||||
def batch_archive_status(batch_id: str):
|
||||
batch = require("batch", batch_id)
|
||||
return archive_service.status(batch_id, batch)
|
||||
|
||||
|
||||
@router.get("/batches/{batch_id}/archive")
|
||||
async def batch_archive(batch_id: str):
|
||||
require("batch", batch_id)
|
||||
path = await asyncio.to_thread(report_export.export_batch, batch_id)
|
||||
batch = require("batch", batch_id)
|
||||
if archive_service.status(batch_id, batch)["status"] == "preparing":
|
||||
raise HTTPException(409, "报告正在打包,请等待准备完成")
|
||||
path = archive_service.archive_path(batch_id)
|
||||
if (
|
||||
not path.is_file()
|
||||
or not batch.get("report_ready")
|
||||
and archive_service.status(batch_id, batch)["status"] != "ready"
|
||||
):
|
||||
path = await asyncio.to_thread(report_export.export_batch, batch_id)
|
||||
return FileResponse(path, filename=f"批次报告-{batch_id[:8]}.zip")
|
||||
|
||||
|
||||
|
||||
@@ -517,9 +517,12 @@ async def get_model_config_and_env():
|
||||
|
||||
@router.get("/server-status")
|
||||
async def get_server_runtime_status():
|
||||
"""Retrieve runtime status, PID, port, and uptime of the Artemis server."""
|
||||
"""Return this server's status without scanning listening ports."""
|
||||
import os
|
||||
from artemis.runtime.server_lifecycle import get_server_status
|
||||
import time
|
||||
|
||||
from artemis.runtime.process_probe import pid_is_alive
|
||||
from artemis.runtime.server_lifecycle import read_server_info
|
||||
|
||||
try:
|
||||
from apps.admin_console.core.state import state
|
||||
@@ -527,18 +530,34 @@ async def get_server_runtime_status():
|
||||
from admin_console.core.state import state
|
||||
|
||||
port = getattr(state, "port", 8000)
|
||||
status = get_server_status(port=port)
|
||||
current_pid = os.getpid()
|
||||
pids = {current_pid}
|
||||
started_at = None
|
||||
info = read_server_info()
|
||||
if info and info.get("port") == port:
|
||||
saved_pid = info.get("pid")
|
||||
if isinstance(saved_pid, int) and saved_pid != current_pid and pid_is_alive(saved_pid):
|
||||
pids.add(saved_pid)
|
||||
if saved_pid == current_pid and isinstance(info.get("started_at"), (int, float)):
|
||||
started_at = float(info["started_at"])
|
||||
if started_at is None:
|
||||
try:
|
||||
import psutil
|
||||
|
||||
started_at = psutil.Process(current_pid).create_time()
|
||||
except Exception:
|
||||
started_at = None
|
||||
# Explicit DTO: the raw metadata file additionally holds the lifecycle
|
||||
# token, cmdline, and filesystem paths, none of which belong on the wire.
|
||||
return {
|
||||
"running": status["running"],
|
||||
"port": status["port"],
|
||||
"pids": status["pids"],
|
||||
"active_pid": status["active_pid"],
|
||||
"uptime_seconds": status["uptime_seconds"],
|
||||
"url": status["url"],
|
||||
"admin_url": status["admin_url"],
|
||||
"current_pid": os.getpid(),
|
||||
"running": True,
|
||||
"port": port,
|
||||
"pids": sorted(pids),
|
||||
"active_pid": current_pid,
|
||||
"uptime_seconds": max(0.0, time.time() - started_at) if started_at else None,
|
||||
"url": f"http://localhost:{port}",
|
||||
"admin_url": f"http://localhost:{port}/admin",
|
||||
"current_pid": current_pid,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -202,7 +202,9 @@ async def run_task(request: RunRequest):
|
||||
# With no explicit serial the probe itself resolves a live target (it
|
||||
# prefers the diagnostics target preference, then any unlocked ready
|
||||
# device); the verified serial is bound below.
|
||||
target_serial = request.device_serial
|
||||
target_serial = (
|
||||
request.runtime_overrides.device_serial if request.runtime_overrides else None
|
||||
) or request.device_serial
|
||||
device_probe = await readiness_engine.run_device_submission_probe(target_serial=target_serial)
|
||||
if device_probe and device_probe.summary in {"Device Locked", "Lock State Unknown"}:
|
||||
locked_serial = (
|
||||
@@ -224,7 +226,7 @@ async def run_task(request: RunRequest):
|
||||
# Only auto-selected targets may be re-bound to the probed device. An
|
||||
# explicitly requested serial is never silently replaced -- if it is
|
||||
# invalid, enqueue_tasks rejects the submission with a clear error.
|
||||
if verified_serial and not request.device_serial:
|
||||
if verified_serial and not target_serial:
|
||||
target_serial = verified_serial
|
||||
|
||||
response = await task_queue_service.enqueue_tasks(
|
||||
@@ -240,6 +242,9 @@ async def run_task(request: RunRequest):
|
||||
ingress=request.ingress or "frontend",
|
||||
session_id=request.session_id,
|
||||
conversation_id=request.conversation_id,
|
||||
runtime_overrides=request.runtime_overrides.model_dump(exclude_none=True)
|
||||
if request.runtime_overrides
|
||||
else None,
|
||||
)
|
||||
|
||||
from apps.admin_console.services.automation_service import register_submission
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
# limitations under the License.
|
||||
|
||||
from pydantic import BaseModel
|
||||
from apps.admin_console.services.task_import import TaskOptions
|
||||
|
||||
|
||||
class RunRequest(BaseModel):
|
||||
@@ -32,6 +33,7 @@ class RunRequest(BaseModel):
|
||||
ingress: str | None = "frontend"
|
||||
session_id: str | None = None
|
||||
conversation_id: str | None = None
|
||||
runtime_overrides: TaskOptions | None = None
|
||||
|
||||
|
||||
class ReplayRequest(BaseModel):
|
||||
|
||||
@@ -24,6 +24,7 @@ import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import signal
|
||||
import socket
|
||||
import sys
|
||||
from types import FrameType
|
||||
|
||||
@@ -421,6 +422,14 @@ queue_goals = state.queue_goals
|
||||
class ArtemisUvicornServer(uvicorn.Server):
|
||||
"""Notify application streams before Uvicorn waits for them to close."""
|
||||
|
||||
async def startup(self, sockets: list[socket.socket] | None = None) -> None:
|
||||
# Reload workers start in a fresh interpreter; restore the actual bind
|
||||
# address there before lifespan publishes runtime metadata.
|
||||
state.host = self.config.host
|
||||
state.port = self.config.port
|
||||
app.state.uvicorn_server = self
|
||||
await super().startup(sockets=sockets)
|
||||
|
||||
@staticmethod
|
||||
def _task_is_active() -> bool:
|
||||
"""Check task state without doing process I/O from a signal handler."""
|
||||
@@ -470,31 +479,40 @@ class ArtemisUvicornServer(uvicorn.Server):
|
||||
|
||||
def run_ui_server(host: str, port: int, reload: bool = False) -> None:
|
||||
"""Run the UI server with bounded, signal-aware graceful shutdown."""
|
||||
# Reserve a listening socket before publishing metadata or running lifespan.
|
||||
# Uvicorn normally runs application startup *before* binding its port; a
|
||||
# duplicate daemon would otherwise recover jobs owned by the real server.
|
||||
family = socket.AF_INET6 if ":" in host else socket.AF_INET
|
||||
try:
|
||||
listener = socket.create_server((host, port), family=family)
|
||||
except OSError as exc:
|
||||
print(f"无法启动服务:{host}:{port} 端口不可用({exc})。现有服务和任务保持不变。")
|
||||
raise SystemExit(1) from exc
|
||||
listener.set_inheritable(True)
|
||||
port = listener.getsockname()[1]
|
||||
state.host = host
|
||||
state.port = port
|
||||
write_server_info(port=port, host=host, lifecycle_token=LIFECYCLE_TOKEN)
|
||||
|
||||
try:
|
||||
if reload:
|
||||
uvicorn.run(
|
||||
"apps.admin_console.server:app",
|
||||
host=host,
|
||||
port=port,
|
||||
reload=True,
|
||||
timeout_graceful_shutdown=5,
|
||||
)
|
||||
return
|
||||
|
||||
config = uvicorn.Config(
|
||||
app,
|
||||
"apps.admin_console.server:app" if reload else app,
|
||||
host=host,
|
||||
port=port,
|
||||
reload=reload,
|
||||
timeout_graceful_shutdown=5,
|
||||
)
|
||||
if reload:
|
||||
from uvicorn.supervisors import ChangeReload
|
||||
|
||||
server = ArtemisUvicornServer(config)
|
||||
ChangeReload(config, target=server.run, sockets=[listener]).run()
|
||||
return
|
||||
|
||||
server = ArtemisUvicornServer(config)
|
||||
app.state.uvicorn_server = server
|
||||
server.run()
|
||||
server.run(sockets=[listener])
|
||||
finally:
|
||||
listener.close()
|
||||
app.state.uvicorn_server = None
|
||||
clear_server_info(port=port, lifecycle_token=LIFECYCLE_TOKEN)
|
||||
|
||||
|
||||
@@ -8,8 +8,10 @@ import time
|
||||
from uuid import UUID
|
||||
|
||||
from apps.admin_console.core.state import state
|
||||
from apps.admin_console.services.automation_store import store, new_id
|
||||
from apps.admin_console.services.automation_store import RECOVERY_ERRORS, store, new_id
|
||||
from apps.admin_console.services.task_import import case_goal, case_title
|
||||
from artemis.config.settings import settings
|
||||
from artemis.runtime.task_limits import task_timeout
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
TERMINAL = {"completed", "failed", "cancelled", "skipped", "interrupted"}
|
||||
@@ -40,9 +42,46 @@ def register_submission(item: dict, case: dict | None = None, formats=None, **ex
|
||||
)
|
||||
|
||||
|
||||
def resolve_recovery_error(job: dict) -> bool:
|
||||
"""Archive a superseded recovery warning without erasing execution failures."""
|
||||
if job.get("error") not in RECOVERY_ERRORS:
|
||||
return False
|
||||
job.setdefault("recovery_events", []).append(
|
||||
{"message": job.pop("error"), "resolved_at": time.time()}
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def recover_batches():
|
||||
"""Recovery never silently repeats a potentially side-effecting interrupted task."""
|
||||
from apps.admin_console.database.repositories.session_repository import SessionRepository
|
||||
from artemis.runtime import DeviceExecutionLock
|
||||
|
||||
repository = SessionRepository(store.path)
|
||||
live_sessions = {
|
||||
str(owner.session_id)
|
||||
for owner in DeviceExecutionLock.get_active_owners().values()
|
||||
if owner.session_id
|
||||
}
|
||||
live_sessions.update(
|
||||
str(row["session_id"])
|
||||
for row in repository.get_all_sessions()
|
||||
if row.get("status") not in TERMINAL and repository.process_is_alive(row.get("pid"))
|
||||
)
|
||||
live_sessions.update(
|
||||
str(item["session_id"])
|
||||
for item in state.queue_items
|
||||
if isinstance(item, dict) and item.get("session_id") and item.get("status") not in TERMINAL
|
||||
)
|
||||
for job in store.jobs():
|
||||
if job["status"] == "completed" and resolve_recovery_error(job):
|
||||
store.save_job(job)
|
||||
if str(job["session_id"]) in live_sessions:
|
||||
if job["status"] not in TERMINAL and str(job["session_id"]) not in state.active_runs:
|
||||
job["recovery_waiting"] = True
|
||||
store.save_job(job)
|
||||
logger.info("Recovery preserved live session %s", job["session_id"])
|
||||
continue
|
||||
if job["status"] in {"running", "queued", "finalizing", "task_paused", "resuming"}:
|
||||
job.update(
|
||||
status=job.get("terminal_status", "interrupted"),
|
||||
@@ -50,6 +89,9 @@ def recover_batches():
|
||||
or ("" if job.get("terminal_status") else "服务中断;未完成任务需手动重跑"),
|
||||
ended_at=job.get("ended_at") or time.time(),
|
||||
)
|
||||
if job["status"] == "completed":
|
||||
resolve_recovery_error(job)
|
||||
logger.info("Recovery resolved session %s to %s", job["session_id"], job["status"])
|
||||
store.save_job(job)
|
||||
for batch in store.records("batch"):
|
||||
if has_legacy_replay(batch["id"]):
|
||||
@@ -62,6 +104,8 @@ def recover_batches():
|
||||
store.put("batch", batch["id"], batch)
|
||||
continue
|
||||
if batch["status"] in {"running", "pausing", "paused"}:
|
||||
if any(str(job["session_id"]) in live_sessions for job in store.jobs(batch["id"])):
|
||||
continue
|
||||
batch.update(
|
||||
status="paused", reason="服务已重启;请检查中断任务,点击继续执行未开始任务"
|
||||
)
|
||||
@@ -86,6 +130,9 @@ async def on_terminal(item: dict, status: str):
|
||||
raise RuntimeError("任务元数据写入失败")
|
||||
if job.get("timed_out"):
|
||||
status = "failed"
|
||||
job.pop("recovery_waiting", None)
|
||||
if status == "completed":
|
||||
resolve_recovery_error(job)
|
||||
job.update(status="finalizing", terminal_status=status, ended_at=time.time())
|
||||
store.save_job(job)
|
||||
try:
|
||||
@@ -115,11 +162,37 @@ async def on_terminal(item: dict, status: str):
|
||||
store.save_job(job)
|
||||
|
||||
|
||||
async def reconcile_recovered_jobs():
|
||||
"""Finalize surviving workers once they exit; never resubmit their goals."""
|
||||
from apps.admin_console.database.repositories.session_repository import SessionRepository
|
||||
from artemis.runtime import DeviceExecutionLock
|
||||
from artemis.runtime.task_result import read_worker_outcome
|
||||
|
||||
waiting = [job for job in store.jobs() if job.get("recovery_waiting")]
|
||||
if not waiting:
|
||||
return
|
||||
repository = SessionRepository(store.path)
|
||||
owners = {str(owner.session_id) for owner in DeviceExecutionLock.get_active_owners().values()}
|
||||
for job in waiting:
|
||||
sid = str(job["session_id"])
|
||||
row = repository.get_session_by_id(sid) or {}
|
||||
if sid in owners or repository.process_is_alive(row.get("pid")):
|
||||
continue
|
||||
outcome = read_worker_outcome(sid)
|
||||
status = job.get("terminal_status") or outcome.get("task_status") or row.get("status")
|
||||
if status not in TERMINAL:
|
||||
status = "interrupted"
|
||||
job["error"] = job.get("error") or "服务中断;未完成任务需手动重跑"
|
||||
store.save_job(job)
|
||||
await on_terminal({"session_id": sid}, status)
|
||||
|
||||
|
||||
async def tick_batches():
|
||||
from apps.admin_console.server import task_queue_service
|
||||
from artemis.runtime import device_pool
|
||||
from apps.admin_console.services.report_export import export_batch
|
||||
|
||||
await reconcile_recovered_jobs()
|
||||
for batch in store.records("batch"):
|
||||
if batch["status"] not in {"running", "paused"}:
|
||||
continue
|
||||
@@ -135,10 +208,11 @@ async def tick_batches():
|
||||
# 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)
|
||||
)
|
||||
from artemis.runtime.task_result import read_worker_outcome
|
||||
|
||||
if read_worker_outcome(str(job["session_id"])):
|
||||
continue # Execution has ended; the worker supervisor bounds cleanup.
|
||||
limit = task_timeout({}, job, batch)
|
||||
task_state = getattr(state, "active_runs", {}).get(str(job["session_id"])) or {}
|
||||
excluded = float(task_state.get("non_execution_seconds") or 0)
|
||||
if task_state.get("non_execution_started_at"):
|
||||
@@ -151,7 +225,17 @@ async def tick_batches():
|
||||
error=f"任务执行超时({limit} 秒,不含暂停/排队时间)", timed_out=True
|
||||
)
|
||||
store.save_job(job)
|
||||
task_queue_service.stop_tasks(session_id=job["session_id"])
|
||||
from artemis.runtime.task_result import save_failure_result
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
save_failure_result,
|
||||
str(job["session_id"]),
|
||||
job["error"],
|
||||
code="task_timeout",
|
||||
)
|
||||
finally:
|
||||
task_queue_service.stop_tasks(session_id=job["session_id"])
|
||||
# on_terminal records the failed case. The next pending
|
||||
# case then runs normally unless stop_on_failure is set.
|
||||
continue
|
||||
@@ -255,8 +339,7 @@ def create_batch(payload: dict) -> dict:
|
||||
"formats": payload.get("formats") or store.preferences()["formats"],
|
||||
"profile": payload.get("profile", "flash"),
|
||||
"device_serial": payload.get("device_serial"),
|
||||
"timeout_seconds": payload.get("timeout_seconds")
|
||||
or int(os.getenv("ARTEMIS_BATCH_TASK_TIMEOUT", "1800")),
|
||||
"timeout_seconds": payload.get("timeout_seconds") or settings.ARTEMIS_TASK_TIMEOUT_SECONDS,
|
||||
"stop_on_failure": payload.get("stop_on_failure", False),
|
||||
"request_id": payload.get("request_id"),
|
||||
}
|
||||
|
||||
@@ -12,6 +12,12 @@ import uuid
|
||||
from artemis.config import DB_PATH
|
||||
|
||||
|
||||
RECOVERY_ERRORS = {
|
||||
"服务中断;未完成任务需手动重跑",
|
||||
"服务中断,需手动重跑",
|
||||
}
|
||||
|
||||
|
||||
def identifier(value: str) -> str:
|
||||
if not re.fullmatch(r"[A-Za-z0-9_-]{1,100}", value):
|
||||
raise ValueError("无效的记录编号")
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Background preparation and observable progress for batch report downloads."""
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
from apps.admin_console.services import report_export
|
||||
|
||||
_lock = threading.Lock()
|
||||
_states: dict[str, dict] = {}
|
||||
|
||||
|
||||
def archive_path(batch_id: str):
|
||||
return report_export.EXPORT_ROOT / "batches" / batch_id / "批次报告.zip"
|
||||
|
||||
|
||||
def _snapshot(state: dict) -> dict:
|
||||
elapsed = max(0, (state.get("finished_at") or time.monotonic()) - state["started_at"])
|
||||
completed, total = state["completed"], state["total"]
|
||||
eta = (
|
||||
max(0, round(elapsed / completed * (total - completed)))
|
||||
if completed and total > completed
|
||||
else None
|
||||
)
|
||||
return {
|
||||
"status": state["status"],
|
||||
"stage": state["stage"],
|
||||
"completed": completed,
|
||||
"total": total,
|
||||
"elapsed_seconds": round(elapsed),
|
||||
"eta_seconds": eta,
|
||||
"error": state.get("error"),
|
||||
}
|
||||
|
||||
|
||||
def status(batch_id: str, batch: dict) -> dict:
|
||||
with _lock:
|
||||
state = _states.get(batch_id)
|
||||
if state:
|
||||
return _snapshot(state)
|
||||
if batch.get("report_ready") and archive_path(batch_id).is_file():
|
||||
return {
|
||||
"status": "ready",
|
||||
"stage": "报告包已就绪",
|
||||
"completed": 0,
|
||||
"total": 0,
|
||||
"elapsed_seconds": 0,
|
||||
"eta_seconds": None,
|
||||
"error": None,
|
||||
}
|
||||
return {
|
||||
"status": "idle",
|
||||
"stage": "尚未开始打包",
|
||||
"completed": 0,
|
||||
"total": 0,
|
||||
"elapsed_seconds": 0,
|
||||
"eta_seconds": None,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
|
||||
def prepare(batch_id: str, batch: dict) -> dict:
|
||||
with _lock:
|
||||
current = _states.get(batch_id)
|
||||
if current and current["status"] == "preparing":
|
||||
return _snapshot(current)
|
||||
if batch.get("report_ready") and archive_path(batch_id).is_file():
|
||||
_states.pop(batch_id, None)
|
||||
return status_from_cache()
|
||||
state = {
|
||||
"status": "preparing",
|
||||
"stage": "等待打包",
|
||||
"completed": 0,
|
||||
"total": len(report_export.store.jobs(batch_id)),
|
||||
"started_at": time.monotonic(),
|
||||
}
|
||||
_states[batch_id] = state
|
||||
|
||||
def update(completed: int, total: int, stage: str) -> None:
|
||||
with _lock:
|
||||
state.update(completed=completed, total=total, stage=stage)
|
||||
|
||||
def run() -> None:
|
||||
try:
|
||||
with report_export.EXPORT_LOCK:
|
||||
latest = report_export.store.get("batch", batch_id)
|
||||
if not (latest and latest.get("report_ready") and archive_path(batch_id).is_file()):
|
||||
report_export.export_batch(batch_id, progress=update)
|
||||
with _lock:
|
||||
state.update(status="ready", stage="报告包已就绪", finished_at=time.monotonic())
|
||||
except Exception as exc:
|
||||
with _lock:
|
||||
state.update(
|
||||
status="failed",
|
||||
stage="打包失败",
|
||||
error=str(exc)[:500],
|
||||
finished_at=time.monotonic(),
|
||||
)
|
||||
|
||||
threading.Thread(target=run, name=f"batch-archive-{batch_id[:8]}", daemon=True).start()
|
||||
return status(batch_id, batch)
|
||||
|
||||
|
||||
def status_from_cache() -> dict:
|
||||
return {
|
||||
"status": "ready",
|
||||
"stage": "报告包已就绪",
|
||||
"completed": 0,
|
||||
"total": 0,
|
||||
"elapsed_seconds": 0,
|
||||
"eta_seconds": None,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
|
||||
def forget(batch_id: str) -> None:
|
||||
with _lock:
|
||||
_states.pop(batch_id, None)
|
||||
@@ -9,6 +9,7 @@ from urllib.parse import urlsplit
|
||||
|
||||
from pydantic import BaseModel, Field, SecretStr, field_validator
|
||||
|
||||
from artemis.config.agent import load_agent_config
|
||||
from artemis.config.paths import get_env_file
|
||||
from artemis.config.settings import settings
|
||||
|
||||
@@ -22,6 +23,13 @@ class ModelSettingsUpdate(BaseModel):
|
||||
timeout_seconds: float = Field(default=300, gt=0, le=3600)
|
||||
max_retries: int = Field(default=5, ge=0, le=10)
|
||||
retry_interval_seconds: float = Field(default=10, ge=0, le=300)
|
||||
rate_limit_retry_seconds: float | None = Field(default=None, ge=0, le=3600)
|
||||
tool_fallback_model: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
tool_fallback_reasoning_effort: (
|
||||
Literal["default", "none", "minimal", "low", "medium", "high", "xhigh", "max"] | None
|
||||
) = None
|
||||
tool_fallback_max_retries: int | None = Field(default=None, ge=0, le=10)
|
||||
tool_fallback_no_progress_attempts: int | None = Field(default=None, ge=1, le=10)
|
||||
reasoning_effort: Literal[
|
||||
"default", "none", "minimal", "low", "medium", "high", "xhigh", "max"
|
||||
] = "high"
|
||||
@@ -44,9 +52,11 @@ class ModelSettingsUpdate(BaseModel):
|
||||
raise ValueError("请输入接口根地址,例如 https://api-n-cd.playones.com/v1")
|
||||
return value
|
||||
|
||||
@field_validator("model")
|
||||
@field_validator("model", "tool_fallback_model")
|
||||
@classmethod
|
||||
def validate_model(cls, value: str) -> str:
|
||||
def validate_model(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
value = value.strip()
|
||||
if not value or any(c in value for c in "\r\n\x00"):
|
||||
raise ValueError("模型名称不能为空或包含换行")
|
||||
@@ -63,6 +73,11 @@ def get_model_settings() -> dict:
|
||||
"timeout_seconds": settings.OPENAI_TIMEOUT_SECONDS,
|
||||
"max_retries": settings.OPENAI_MAX_RETRIES,
|
||||
"retry_interval_seconds": settings.OPENAI_RETRY_INTERVAL_SECONDS,
|
||||
"rate_limit_retry_seconds": settings.OPENAI_RATE_LIMIT_RETRY_SECONDS,
|
||||
"tool_fallback_model": settings.OPENAI_TOOL_FALLBACK_MODEL,
|
||||
"tool_fallback_reasoning_effort": settings.OPENAI_TOOL_FALLBACK_REASONING_EFFORT,
|
||||
"tool_fallback_max_retries": settings.OPENAI_TOOL_FALLBACK_MAX_RETRIES,
|
||||
"tool_fallback_no_progress_attempts": settings.ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS,
|
||||
"reasoning_effort": settings.OPENAI_REASONING_EFFORT,
|
||||
"model_presets": [
|
||||
"gpt-5.6-luna",
|
||||
@@ -85,6 +100,20 @@ def save_model_settings(update: ModelSettingsUpdate) -> dict:
|
||||
"OPENAI_RETRY_INTERVAL_SECONDS": str(update.retry_interval_seconds),
|
||||
"OPENAI_REASONING_EFFORT": update.reasoning_effort,
|
||||
}
|
||||
optional_settings = {
|
||||
"rate_limit_retry_seconds": ("OPENAI_RATE_LIMIT_RETRY_SECONDS", float),
|
||||
"tool_fallback_model": ("OPENAI_TOOL_FALLBACK_MODEL", str),
|
||||
"tool_fallback_reasoning_effort": ("OPENAI_TOOL_FALLBACK_REASONING_EFFORT", str),
|
||||
"tool_fallback_max_retries": ("OPENAI_TOOL_FALLBACK_MAX_RETRIES", int),
|
||||
"tool_fallback_no_progress_attempts": (
|
||||
"ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS",
|
||||
int,
|
||||
),
|
||||
}
|
||||
for field_name, (env_name, expected_type) in optional_settings.items():
|
||||
value = getattr(update, field_name)
|
||||
if value is not None:
|
||||
values[env_name] = str(value)
|
||||
if update.api_key and update.api_key.get_secret_value().strip():
|
||||
values["OPENAI_API_KEY"] = update.api_key.get_secret_value().strip()
|
||||
persist_environment(values)
|
||||
@@ -94,6 +123,10 @@ def save_model_settings(update: ModelSettingsUpdate) -> dict:
|
||||
settings.OPENAI_MAX_RETRIES = update.max_retries
|
||||
settings.OPENAI_RETRY_INTERVAL_SECONDS = update.retry_interval_seconds
|
||||
settings.OPENAI_REASONING_EFFORT = update.reasoning_effort
|
||||
for field_name, (env_name, expected_type) in optional_settings.items():
|
||||
value = getattr(update, field_name)
|
||||
if value is not None:
|
||||
setattr(settings, env_name, expected_type(value))
|
||||
if "OPENAI_API_KEY" in values:
|
||||
settings.OPENAI_API_KEY = SecretStr(values["OPENAI_API_KEY"])
|
||||
from artemis.llm.router import ModelFactory
|
||||
@@ -137,14 +170,37 @@ class CapabilitySettings(BaseModel):
|
||||
recording_enabled: bool = False
|
||||
outputter_enabled: bool = True
|
||||
step_summarizer_enabled: bool = True
|
||||
screenshot_limit_enabled: bool = True
|
||||
screenshot_max_long_edge: int = 2048
|
||||
task_timeout_seconds: int = Field(
|
||||
default_factory=lambda: settings.ARTEMIS_TASK_TIMEOUT_SECONDS, ge=30, le=86400
|
||||
)
|
||||
observation_timeout_seconds: float = Field(
|
||||
default_factory=lambda: settings.ARTEMIS_OBSERVATION_TIMEOUT_SECONDS, gt=0, le=300
|
||||
)
|
||||
observation_max_attempts: int = Field(
|
||||
default_factory=lambda: settings.ARTEMIS_OBSERVATION_MAX_ATTEMPTS, ge=1, le=5
|
||||
)
|
||||
device_command_timeout_seconds: float = Field(
|
||||
default_factory=lambda: settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS, gt=0, le=60
|
||||
)
|
||||
action_failure_attempts: int = Field(
|
||||
default_factory=lambda: settings.ARTEMIS_ACTION_FAILURE_ATTEMPTS, ge=1, le=10
|
||||
)
|
||||
worker_cleanup_timeout_seconds: float = Field(
|
||||
default_factory=lambda: settings.ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS, gt=0, le=300
|
||||
)
|
||||
|
||||
|
||||
def get_capability_settings() -> CapabilitySettings:
|
||||
screenshot = load_agent_config().screenshot
|
||||
return CapabilitySettings(
|
||||
video_enabled=settings.ARTEMIS_VIDEO_ENABLED,
|
||||
recording_enabled=settings.ARTEMIS_RECORDING_ENABLED,
|
||||
outputter_enabled=settings.ARTEMIS_OUTPUTTER_ENABLED,
|
||||
step_summarizer_enabled=settings.ARTEMIS_STEP_SUMMARIZER_ENABLED,
|
||||
screenshot_limit_enabled=screenshot.enabled,
|
||||
screenshot_max_long_edge=screenshot.max_long_edge,
|
||||
)
|
||||
|
||||
|
||||
@@ -154,6 +210,13 @@ def save_capability_settings(update: CapabilitySettings) -> CapabilitySettings:
|
||||
"ARTEMIS_RECORDING_ENABLED": update.recording_enabled,
|
||||
"ARTEMIS_OUTPUTTER_ENABLED": update.outputter_enabled,
|
||||
"ARTEMIS_STEP_SUMMARIZER_ENABLED": update.step_summarizer_enabled,
|
||||
"ARTEMIS_SCREENSHOT_LIMIT_ENABLED": update.screenshot_limit_enabled,
|
||||
"ARTEMIS_TASK_TIMEOUT_SECONDS": update.task_timeout_seconds,
|
||||
"ARTEMIS_OBSERVATION_TIMEOUT_SECONDS": update.observation_timeout_seconds,
|
||||
"ARTEMIS_OBSERVATION_MAX_ATTEMPTS": update.observation_max_attempts,
|
||||
"ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS": update.device_command_timeout_seconds,
|
||||
"ARTEMIS_ACTION_FAILURE_ATTEMPTS": update.action_failure_attempts,
|
||||
"ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS": update.worker_cleanup_timeout_seconds,
|
||||
}
|
||||
persist_environment({key: str(value).lower() for key, value in values.items()})
|
||||
for key, value in values.items():
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Evidence-backed Word/Excel artifacts, independent of the language model runtime."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import cast
|
||||
from typing import Callable, cast
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
@@ -88,6 +88,10 @@ def failure_reason(directory: Path, status: str, recorded: str | None) -> str:
|
||||
)
|
||||
return safe[:500]
|
||||
|
||||
from apps.admin_console.services.automation_store import RECOVERY_ERRORS
|
||||
|
||||
if status == "completed" and recorded in RECOVERY_ERRORS:
|
||||
return ""
|
||||
if recorded:
|
||||
return redact(recorded)
|
||||
if status not in {"failed", "interrupted"}:
|
||||
@@ -95,6 +99,11 @@ 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"])
|
||||
result = read_json(directory / "result.json", {})
|
||||
if isinstance(result, dict) and result.get("status") == "failed":
|
||||
reason = result.get("error") or result.get("explanation")
|
||||
if reason:
|
||||
return redact(reason)
|
||||
log_lines: list[str] = []
|
||||
for name in ("stdout.log", "stderr.log"):
|
||||
path = directory / name
|
||||
@@ -695,7 +704,7 @@ def export_session_media(session_id: str, kind: str) -> Path:
|
||||
return target
|
||||
|
||||
|
||||
def export_batch(batch_id: str) -> Path:
|
||||
def export_batch(batch_id: str, progress: Callable[[int, int, str], None] | None = None) -> Path:
|
||||
with EXPORT_LOCK:
|
||||
batch = store.get("batch", identifier(batch_id))
|
||||
if not batch:
|
||||
@@ -704,6 +713,8 @@ def export_batch(batch_id: str) -> Path:
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
formats = batch["formats"]
|
||||
records = [session_report(job["session_id"]) for job in store.jobs(batch_id)]
|
||||
if progress:
|
||||
progress(0, len(records), "正在生成批次汇总")
|
||||
rows = [("任务编号", "任务名称", "执行状态", "验证结论", "异常原因")]
|
||||
rows += [
|
||||
(r["id"], r["name"], STATUS.get(r["status"], r["status"]), r["verdict"], r["error"])
|
||||
@@ -734,9 +745,18 @@ def export_batch(batch_id: str) -> Path:
|
||||
for path in outputs:
|
||||
archive.write(path, path.name)
|
||||
for number, report in enumerate(records, 1):
|
||||
if progress:
|
||||
progress(number - 1, len(records), f"正在打包任务 {number}/{len(records)}")
|
||||
prefix = f"任务报告/{number:04d}_{archive_case_name(report)}"
|
||||
for path in export_session(report["id"], formats):
|
||||
archive.write(
|
||||
path, f"任务报告/{number:04d}_{archive_case_name(report)}/{path.name}"
|
||||
)
|
||||
archive.write(path, f"{prefix}/{path.name}")
|
||||
for source, name in session_screenshots(report):
|
||||
archive.write(source, f"{prefix}/步骤截图/{name}")
|
||||
for source, name in session_videos(report):
|
||||
archive.write(source, f"{prefix}/任务录屏/{name}")
|
||||
if progress:
|
||||
progress(number, len(records), f"已打包任务 {number}/{len(records)}")
|
||||
if progress:
|
||||
progress(len(records), len(records), "正在完成压缩包")
|
||||
temporary.replace(target)
|
||||
return target
|
||||
@@ -42,6 +42,11 @@ class TaskOptions(BaseModel):
|
||||
max_retries: int | None = Field(default=None, ge=0, le=10)
|
||||
retry_interval_seconds: float | None = Field(default=None, ge=0, le=300)
|
||||
timeout_seconds: int | None = Field(default=None, ge=30, le=86400)
|
||||
observation_timeout_seconds: float | None = Field(default=None, gt=0, le=300)
|
||||
observation_max_attempts: int | None = Field(default=None, ge=1, le=5)
|
||||
device_command_timeout_seconds: float | None = Field(default=None, gt=0, le=60)
|
||||
action_failure_attempts: int | None = Field(default=None, ge=1, le=10)
|
||||
worker_cleanup_timeout_seconds: float | None = Field(default=None, gt=0, le=300)
|
||||
|
||||
|
||||
class TaskCase(BaseModel):
|
||||
|
||||
@@ -581,6 +581,10 @@ class TaskQueueService:
|
||||
"retry_interval_seconds": "OPENAI_RETRY_INTERVAL_SECONDS",
|
||||
"record_video": "ARTEMIS_RECORDING_ENABLED",
|
||||
"analyze_video": "ARTEMIS_VIDEO_ENABLED",
|
||||
"observation_timeout_seconds": "ARTEMIS_OBSERVATION_TIMEOUT_SECONDS",
|
||||
"observation_max_attempts": "ARTEMIS_OBSERVATION_MAX_ATTEMPTS",
|
||||
"device_command_timeout_seconds": "ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS",
|
||||
"action_failure_attempts": "ARTEMIS_ACTION_FAILURE_ATTEMPTS",
|
||||
}.items():
|
||||
if overrides.get(option) is not None:
|
||||
env[variable] = (
|
||||
@@ -630,6 +634,72 @@ class TaskQueueService:
|
||||
env["RESULTS_OUTPUT_PATH"] = str(TRACES_PATH / str(sess_id) / "result.json")
|
||||
return cmd, env
|
||||
|
||||
@classmethod
|
||||
async def _await_worker_with_budget(cls, proc, task_item: dict, run_key: str) -> int:
|
||||
from artemis.runtime.task_limits import ExecutionBudget, task_timeout
|
||||
from artemis.config.settings import settings
|
||||
from artemis.runtime.task_result import save_failure_result, read_worker_outcome
|
||||
from artemis.runtime.task_control import read_state
|
||||
from apps.admin_console.services.automation_store import store
|
||||
|
||||
sid = str(task_item.get("session_id") or run_key)
|
||||
job = store.job(sid)
|
||||
batch = store.get("batch", job["batch_id"]) if job and job.get("batch_id") else None
|
||||
limit = task_timeout(task_item, job, batch)
|
||||
budget = ExecutionBudget(limit)
|
||||
cleanup_limit = (task_item.get("runtime_overrides") or {}).get(
|
||||
"worker_cleanup_timeout_seconds"
|
||||
) or settings.ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS
|
||||
finalized_at = None
|
||||
waiter = asyncio.create_task(cls._wait_for_worker_process(proc))
|
||||
try:
|
||||
while True:
|
||||
done, _ = await asyncio.wait({waiter}, timeout=0.25)
|
||||
if waiter in done:
|
||||
return waiter.result()
|
||||
outcome = read_worker_outcome(sid)
|
||||
if outcome:
|
||||
finalized_at = finalized_at if finalized_at is not None else time.monotonic()
|
||||
if time.monotonic() - finalized_at >= cleanup_limit:
|
||||
logger.warning(
|
||||
"%s: task finalized but worker cleanup exceeded %s seconds; reclaiming process",
|
||||
sid,
|
||||
cleanup_limit,
|
||||
)
|
||||
await cls._terminate_worker_process(proc)
|
||||
return await waiter
|
||||
continue # Final result is authoritative; only bound resource cleanup now.
|
||||
cls._sync_control_states()
|
||||
run = state.active_runs.get(run_key, {})
|
||||
remaining = budget.update(
|
||||
excluded=bool(run.get("parked")) or read_state(sid).get("status") == "queued"
|
||||
)
|
||||
if remaining > 0:
|
||||
continue
|
||||
error = f"任务执行超时({limit} 秒,不含暂停/排队时间)"
|
||||
task_item.update(timed_out=True, error=error)
|
||||
logger.error("%s: %s", sid, error)
|
||||
try:
|
||||
latest = store.job(sid)
|
||||
if latest:
|
||||
latest.update(timed_out=True, error=error)
|
||||
store.save_job(latest)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Cannot persist timeout for %s; terminating worker anyway", sid
|
||||
)
|
||||
await cls._terminate_worker_process(proc)
|
||||
returncode = await waiter
|
||||
try:
|
||||
await asyncio.to_thread(save_failure_result, sid, error, code="task_timeout")
|
||||
except OSError:
|
||||
logger.exception("Cannot save timeout report for %s", sid)
|
||||
return returncode
|
||||
finally:
|
||||
if not waiter.done():
|
||||
waiter.cancel()
|
||||
await asyncio.gather(waiter, return_exceptions=True)
|
||||
|
||||
@classmethod
|
||||
def _register_worker_run(
|
||||
cls,
|
||||
@@ -721,6 +791,10 @@ class TaskQueueService:
|
||||
returncode,
|
||||
manual_stop,
|
||||
)
|
||||
from apps.admin_console.services.automation_store import store
|
||||
|
||||
if (store.job(str(sess_id)) or {}).get("timed_out"):
|
||||
new_status, should_persist = "failed", True
|
||||
if (
|
||||
state.is_shutting_down
|
||||
and not manual_stop
|
||||
@@ -943,7 +1017,7 @@ class TaskQueueService:
|
||||
await cls._terminate_if_cancelled_during_launch(run_key, sess_id, proc)
|
||||
|
||||
# 3. Await subprocess completion
|
||||
returncode = await cls._wait_for_worker_process(proc)
|
||||
returncode = await cls._await_worker_with_budget(proc, task_item, run_key)
|
||||
print(f"[QueueWorker] Task [{sess_id}] exited with returncode {returncode}")
|
||||
|
||||
manual_stop = run_key in state.manually_stopped_run_ids or bool(
|
||||
@@ -1141,6 +1215,12 @@ class TaskQueueService:
|
||||
forwarded to the worker as ``--verification-level`` / ``--explorer-pro-mode``;
|
||||
they are normalised here so the queue item and the CLI see one spelling.
|
||||
"""
|
||||
if runtime_overrides:
|
||||
profile = runtime_overrides.get("profile") or profile
|
||||
device_serial = runtime_overrides.get("device_serial") or device_serial
|
||||
locked_app_package = runtime_overrides.get("locked_app_package") or locked_app_package
|
||||
verification_level = runtime_overrides.get("verification_level") or verification_level
|
||||
explorer_mode = runtime_overrides.get("explorer_mode") or explorer_mode
|
||||
verification_level = (
|
||||
str(verification_level).strip().lower() or None if verification_level else None
|
||||
)
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "artemis-showcase-ui",
|
||||
"version": "1.2.3",
|
||||
"version": "1.3.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "artemis-showcase-ui",
|
||||
"version": "1.2.3",
|
||||
"version": "1.3.2",
|
||||
"dependencies": {
|
||||
"@angular/cdk": "^22.1.5",
|
||||
"@angular/common": "^22.1.4",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "artemis-showcase-ui",
|
||||
"version": "1.2.3",
|
||||
"version": "1.3.2",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve --proxy-config proxy.conf.json",
|
||||
|
||||
+15
-2
@@ -71,8 +71,21 @@
|
||||
@if(b.status === 'running'){<button [disabled]="busy()" (click)="batchAction('pause')">暂停后续任务</button>}
|
||||
@if(b.status === 'draft' || b.status === 'paused'){<button [disabled]="busy()" (click)="batchAction('resume')">{{b.status === 'draft' ? '开始执行' : '继续未开始任务'}}</button>}
|
||||
@if(b.status !== 'running' && b.status !== 'deleting'){<button [disabled]="busy()" (click)="batchAction('retry_failed')">复制失败项为新批次</button>}
|
||||
<a [href]="'/api/automation/batches/'+b.id+'/archive'">下载批次报告 ZIP</a>
|
||||
</div><p class="muted">服务重启后等待手动继续。失败项不会自动重试;暂停不打断当前任务。</p>
|
||||
<button type="button" [disabled]="archiveBusy()" (click)="downloadBatchArchive(b.id)">{{archiveBusy() && archiveBatchId() === b.id ? '正在准备报告包…' : '下载批次报告 ZIP'}}</button>
|
||||
</div><p class="muted">服务重启后等待手动继续。失败项不会自动重试;暂停不打断当前任务。</p>
|
||||
@if(archiveBatchId() === b.id && archiveProgress(); as progress){
|
||||
<div class="archive-progress" role="status" aria-live="polite" [class.archive-failed]="progress.status === 'failed'">
|
||||
<div class="archive-progress-heading">
|
||||
@if(progress.status === 'preparing'){<span class="archive-spinner" aria-hidden="true"></span>}
|
||||
<strong>{{progress.stage}}</strong>
|
||||
</div>
|
||||
@if(progress.status === 'preparing'){
|
||||
<div class="archive-track" role="progressbar" [attr.aria-valuenow]="progress.total ? progress.completed : null" [attr.aria-valuemin]="progress.total ? 0 : null" [attr.aria-valuemax]="progress.total || null" [attr.aria-label]="'批次报告打包进度:' + progress.stage"><span [style.width.%]="progress.total ? progress.completed / progress.total * 100 : 8"></span></div>
|
||||
<p>已处理 {{progress.completed}} / {{progress.total}} 个任务 · 已用 {{duration(progress.elapsed_seconds)}}@if(progress.eta_seconds !== null){ · 预计还需约 {{duration(progress.eta_seconds)}}}</p>
|
||||
} @else if(progress.status === 'failed'){<p>{{progress.error}}。可点击下载按钮重试。</p>}
|
||||
@else if(progress.status === 'ready'){<p>打包完成,下载已开始。</p>}
|
||||
</div>
|
||||
}
|
||||
@if(b.report_error){<p class="error">报告生成失败:{{b.report_error}}</p>}
|
||||
@for(j of b.jobs; track j.session_id){<div class="job"><button (click)="agent.selectSession(j.session_id,true); tab.set('reports')" [title]="j.case.goal">{{j.case.case_id ? j.case.case_id + ' ' : ''}}{{j.case.name}}</button><span>{{label(j.status)}}</span>@if(j.error){<small>{{j.error}}</small>}</div>}
|
||||
</section>}
|
||||
|
||||
@@ -18,6 +18,14 @@ input[type=checkbox]{width:15px;height:15px;accent-color:#2563eb;vertical-align:
|
||||
.device{background:#f8fafc;padding:12px 16px;}.device label{margin:0;}
|
||||
.record{display:flex;width:100%;text-align:left;align-items:flex-start;flex-direction:column;gap:4px;margin:8px 0 0;padding:12px;overflow-wrap:anywhere;}.record span{color:#64748b;font-size:12px;font-weight:400;}.record.selected{border-color:#93c5fd;background:#eff6ff;}.record.selected strong{color:#1d4ed8;}
|
||||
.batch-record{display:flex;gap:8px;align-items:stretch;margin-top:8px;}.batch-record .record{flex:1;min-width:0;width:auto;margin:0;}.batch-record .delete-batch{flex:0 0 40px;min-width:40px;padding:0;color:#b91c1c;}.batch-record .delete-batch:hover:not(:disabled){background:#fff1f2;border-color:#fda4af;}.batch-record .delete-batch .material-symbols-outlined{font-size:19px;}
|
||||
.archive-progress{margin:12px 0;padding:11px 13px;border:1px solid #bfdbfe;border-radius:10px;background:#f8fbff;color:#1e40af;}
|
||||
.archive-progress-heading{display:flex;align-items:center;gap:8px;}
|
||||
.archive-progress p{margin:7px 0 0;font-size:12px;color:#475569;}
|
||||
.archive-progress.archive-failed{border-color:#fecdd3;background:#fff1f2;color:#be123c;}
|
||||
.archive-track{height:7px;margin-top:10px;border-radius:999px;background:#dbeafe;overflow:hidden;}
|
||||
.archive-track span{display:block;height:100%;border-radius:inherit;background:#3b82f6;transition:width .3s ease;}
|
||||
.archive-spinner{width:13px;height:13px;border:2px solid #bfdbfe;border-top-color:#2563eb;border-radius:50%;animation:archive-spin .8s linear infinite;}
|
||||
@keyframes archive-spin{to{transform:rotate(360deg);}}
|
||||
.code{font-family:Consolas,monospace;font-size:12px;line-height:1.6;resize:vertical;background:#f8fafc;}.table-wrap{max-height:260px;overflow:auto;border:1px solid #e2e8f0;border-radius:8px;margin-bottom:14px;}table{width:100%;border-collapse:collapse;}th,td{padding:9px 10px;border-bottom:1px solid #e2e8f0;text-align:left;overflow-wrap:anywhere;}th{background:#f8fafc;color:#475569;font-weight:500;}
|
||||
.preview{white-space:pre-wrap;max-height:400px;overflow:auto;font:inherit;color:#334155;background:#f8fafc;border-radius:8px;padding:12px;border:1px solid #e2e8f0;}
|
||||
.job{display:flex;flex-wrap:wrap;gap:8px;align-items:center;padding:10px 0;border-bottom:1px solid #f1f5f9;}.job:last-child{border-bottom:0;}.job button{flex:1;justify-content:flex-start;text-align:left;min-width:150px;}.job span{font-size:12px;background:#f1f5f9;color:#475569;border-radius:5px;padding:3px 7px;}.job small{width:100%;}details{margin:14px 0;}summary{color:#475569;cursor:pointer;padding:8px 0;}
|
||||
|
||||
+55
-1
@@ -14,12 +14,13 @@ describe('AutomationPanelComponent batch deletion', () => {
|
||||
agentStatus: signal('idle'),
|
||||
sessions: signal<any[]>([])
|
||||
};
|
||||
const http = jasmine.createSpyObj<HttpClient>('HttpClient', ['get', 'delete']);
|
||||
const http = jasmine.createSpyObj<HttpClient>('HttpClient', ['get', 'post', 'delete']);
|
||||
|
||||
beforeEach(async () => {
|
||||
(http.get.and as any).callFake((url: string) => of(url.endsWith('/preferences')
|
||||
? { formats: ['docx', 'xlsx'], batch_timeout_seconds: 1800 } : []));
|
||||
http.delete.and.returnValue(of({ status: 'deleted' }));
|
||||
http.post.and.returnValue(of({ status: 'ready', stage: '报告包已就绪', completed: 0, total: 0, elapsed_seconds: 0, eta_seconds: null, error: null }));
|
||||
http.delete.calls.reset();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AutomationPanelComponent],
|
||||
@@ -77,4 +78,57 @@ describe('AutomationPanelComponent batch deletion', () => {
|
||||
expect(root.querySelector('.report-body')?.textContent).toContain('第二行');
|
||||
expect(root.querySelector('.report-body table td')?.textContent).toBe('设置');
|
||||
});
|
||||
|
||||
it('prepares a batch archive before starting the browser download', async () => {
|
||||
const fixture = TestBed.createComponent(AutomationPanelComponent);
|
||||
const component = fixture.componentInstance;
|
||||
component.tab.set('batches');
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||
component.batch.set({ id: 'batch-3', name: '批量测试', status: 'completed', jobs: [] });
|
||||
const click = spyOn(HTMLAnchorElement.prototype, 'click');
|
||||
fixture.detectChanges();
|
||||
(fixture.nativeElement.querySelector('.actions button:last-child') as HTMLButtonElement).click();
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||
fixture.detectChanges();
|
||||
expect(http.post).toHaveBeenCalledWith('/api/automation/batches/batch-3/archive/prepare', {});
|
||||
expect(click).toHaveBeenCalled();
|
||||
expect(fixture.nativeElement.querySelector('.archive-progress')?.textContent).toContain('下载已开始');
|
||||
});
|
||||
|
||||
it('shows archive errors and lets the user retry', async () => {
|
||||
http.post.and.returnValue(of({ status: 'failed', stage: '打包失败', completed: 1, total: 2, elapsed_seconds: 3, eta_seconds: null, error: '截图文件无法读取' }));
|
||||
const fixture = TestBed.createComponent(AutomationPanelComponent);
|
||||
const component = fixture.componentInstance;
|
||||
component.tab.set('batches');
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||
component.batch.set({ id: 'batch-4', name: '批量测试', status: 'completed', jobs: [] });
|
||||
fixture.detectChanges();
|
||||
(fixture.nativeElement.querySelector('.actions button:last-child') as HTMLButtonElement).click();
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('.archive-progress')?.textContent).toContain('截图文件无法读取');
|
||||
expect((fixture.nativeElement.querySelector('.actions button:last-child') as HTMLButtonElement).disabled).toBeFalse();
|
||||
});
|
||||
|
||||
it('shows live archive progress and estimated remaining time while preparing', async () => {
|
||||
http.post.and.returnValue(of({ status: 'preparing', stage: '正在打包任务 2/4', completed: 1, total: 4, elapsed_seconds: 12, eta_seconds: 36, error: null }));
|
||||
(http.get.and as any).callFake((url: string) => of(url.endsWith('/status')
|
||||
? { status: 'ready', stage: '报告包已就绪', completed: 4, total: 4, elapsed_seconds: 13, eta_seconds: null, error: null }
|
||||
: url.endsWith('/preferences') ? { formats: ['docx', 'xlsx'], batch_timeout_seconds: 1800 } : []));
|
||||
const fixture = TestBed.createComponent(AutomationPanelComponent);
|
||||
const component = fixture.componentInstance;
|
||||
component.tab.set('batches');
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||
component.batch.set({ id: 'batch-5', name: '批量测试', status: 'completed', jobs: [] });
|
||||
spyOn(HTMLAnchorElement.prototype, 'click');
|
||||
fixture.detectChanges();
|
||||
(fixture.nativeElement.querySelector('.actions button:last-child') as HTMLButtonElement).click();
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||
fixture.detectChanges();
|
||||
const progress = fixture.nativeElement.querySelector('.archive-progress') as HTMLElement;
|
||||
expect(progress.textContent).toContain('已处理 1 / 4 个任务');
|
||||
expect(progress.textContent).toContain('预计还需约 36 秒');
|
||||
expect(progress.querySelector('.archive-spinner')).not.toBeNull();
|
||||
fixture.destroy();
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,16 @@ import { AgentService } from '../../services/agent.service';
|
||||
import { SystemService } from '../../services/system.service';
|
||||
import { renderMarkdownToHtml } from '../../utils/markdown-parser.util';
|
||||
|
||||
interface ArchiveProgress {
|
||||
status: 'idle' | 'preparing' | 'ready' | 'failed';
|
||||
stage: string;
|
||||
completed: number;
|
||||
total: number;
|
||||
elapsed_seconds: number;
|
||||
eta_seconds: number | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-automation-panel', standalone: true, imports: [CommonModule, FormsModule],
|
||||
templateUrl: './automation-panel.component.html', styleUrl: './automation-panel.component.scss'
|
||||
@@ -16,6 +26,8 @@ export class AutomationPanelComponent {
|
||||
tab = signal('reports'); error = signal(''); notice = signal(''); busy = signal(false);
|
||||
batches = signal<any[]>([]); preview = signal<any>(null);
|
||||
batch = signal<any>(null); report = signal<any>(null);
|
||||
archiveProgress = signal<ArchiveProgress | null>(null);
|
||||
archiveBatchId = signal(''); archiveBusy = signal(false);
|
||||
guidance = '';
|
||||
taskStatus = computed(() => {
|
||||
const sid = this.agent.currentSessionId();
|
||||
@@ -40,6 +52,38 @@ export class AutomationPanelComponent {
|
||||
formats() { return [ ...(this.word ? ['docx'] : []), ...(this.excel ? ['xlsx'] : []) ]; }
|
||||
label(s: string) { return ({draft:'待开始',running:'执行中',paused:'已暂停',task_paused:'等待指导',resuming:'等待设备恢复',completed:'执行完成',failed:'失败',cancelled:'已取消',pending:'待执行',queued:'排队中',interrupted:'中断',skipped:'跳过',finalizing:'正在生成报告',deleting:'删除中,可重试清理'} as Record<string,string>)[s] || s; }
|
||||
renderReport(content: string) { return renderMarkdownToHtml(content || ''); }
|
||||
duration(seconds: number) { const minutes = Math.floor(seconds / 60); return minutes ? `${minutes} 分 ${seconds % 60} 秒` : `${seconds} 秒`; }
|
||||
downloadBatchArchive(id: string) { void this.prepareBatchArchive(id); }
|
||||
private async prepareBatchArchive(id: string) {
|
||||
if (this.archiveBusy()) return;
|
||||
this.archiveBusy.set(true); this.archiveBatchId.set(id);
|
||||
this.archiveProgress.set({ status: 'preparing', stage: '正在请求打包', completed: 0, total: 0, elapsed_seconds: 0, eta_seconds: null, error: null });
|
||||
const base = `/api/automation/batches/${encodeURIComponent(id)}/archive`;
|
||||
try {
|
||||
let progress = await firstValueFrom(this.http.post<ArchiveProgress>(`${base}/prepare`, {}));
|
||||
while (!this.destroyed && progress.status === 'preparing') {
|
||||
this.archiveProgress.set(progress);
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 1000));
|
||||
if (this.destroyed) return;
|
||||
progress = await firstValueFrom(this.http.get<ArchiveProgress>(`${base}/status`));
|
||||
}
|
||||
if (this.destroyed) return;
|
||||
this.archiveProgress.set(progress);
|
||||
if (progress.status === 'failed') throw Error(progress.error || '批次报告打包失败');
|
||||
if (progress.status !== 'ready') throw Error('打包任务已中断,请重试');
|
||||
const link = document.createElement('a');
|
||||
link.href = base;
|
||||
link.download = `批次报告-${id.slice(0, 8)}.zip`;
|
||||
document.body.appendChild(link); link.click(); link.remove();
|
||||
this.notice.set('报告包已就绪,正在开始下载');
|
||||
} catch (e: any) {
|
||||
const detail = e.error?.detail;
|
||||
this.archiveProgress.set({
|
||||
...(this.archiveProgress() || { completed: 0, total: 0, elapsed_seconds: 0, eta_seconds: null }),
|
||||
status: 'failed', stage: '打包失败', error: typeof detail === 'string' ? detail : e.message || '下载失败'
|
||||
});
|
||||
} finally { this.archiveBusy.set(false); }
|
||||
}
|
||||
async action(fn: () => Promise<void>) {
|
||||
if (this.busy()) return;
|
||||
this.busy.set(true); this.error.set(''); this.notice.set('');
|
||||
|
||||
+19
@@ -338,6 +338,8 @@ limitations under the License.
|
||||
playsinline
|
||||
(loadedmetadata)="onLoadedMetadata()"
|
||||
(timeupdate)="onTimeUpdate()"
|
||||
(seeked)="onSeeked()"
|
||||
(emptied)="onPause()"
|
||||
(play)="onPlay()"
|
||||
(pause)="onPause()"
|
||||
(ended)="onEnded()"
|
||||
@@ -354,6 +356,7 @@ limitations under the License.
|
||||
<input
|
||||
type="range"
|
||||
class="scrubber-range"
|
||||
aria-label="录像进度"
|
||||
[min]="0"
|
||||
[max]="duration() || 100"
|
||||
[step]="0.1"
|
||||
@@ -361,6 +364,22 @@ limitations under the License.
|
||||
(input)="onScrub($event)"
|
||||
/>
|
||||
<div class="scrubber-progress-fill" [style.width.%]="progressPercent()"></div>
|
||||
@if (videoStepMarkers().length > 0) {
|
||||
<div class="video-markers" aria-label="录像关键步骤">
|
||||
@for (marker of videoStepMarkers(); track marker.time) {
|
||||
<button
|
||||
type="button"
|
||||
class="video-marker"
|
||||
[class.passed]="currentTime() >= marker.time"
|
||||
[class.failed]="marker.failed"
|
||||
[style.left.%]="marker.percent"
|
||||
[title]="videoMarkerLabel(marker)"
|
||||
[attr.aria-label]="'跳转到' + videoMarkerLabel(marker)"
|
||||
(click)="seekToVideoMarker(marker)"
|
||||
></button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<!-- Bottom Controls Row -->
|
||||
<div class="controls-row">
|
||||
|
||||
+32
@@ -724,6 +724,38 @@
|
||||
box-shadow: 0 0 8px rgba(2, 132, 199, 0.4);
|
||||
}
|
||||
|
||||
.video-markers {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 6;
|
||||
pointer-events: none;
|
||||
|
||||
.video-marker {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
padding: 0;
|
||||
border: 2px solid #fff;
|
||||
border-radius: 50%;
|
||||
background: #64748b;
|
||||
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.25);
|
||||
transform: translate(-50%, -50%);
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
transition: transform 0.15s ease, background 0.15s ease;
|
||||
|
||||
&.passed { background: #0284c7; }
|
||||
&.failed { background: #ef4444; }
|
||||
&:hover, &:focus-visible {
|
||||
z-index: 1;
|
||||
transform: translate(-50%, -50%) scale(1.35);
|
||||
outline: 2px solid #38bdf8;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.step-ticks-wrapper {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
|
||||
+243
@@ -0,0 +1,243 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { signal } from '@angular/core';
|
||||
import { AgentService } from '../../services/agent.service';
|
||||
import { StepReplayFrame } from '../../core/models/stream.model';
|
||||
import { FloatingVideoPlayerComponent } from './floating-video-player.component';
|
||||
|
||||
describe('FloatingVideoPlayerComponent video markers', () => {
|
||||
let fixture: ComponentFixture<FloatingVideoPlayerComponent>;
|
||||
let component: FloatingVideoPlayerComponent;
|
||||
let video: HTMLVideoElement;
|
||||
let paused: boolean;
|
||||
let seeking: boolean;
|
||||
let time: number;
|
||||
let play: jasmine.Spy;
|
||||
let agentService: ReturnType<typeof createAgentService>;
|
||||
|
||||
function createAgentService() {
|
||||
const start = 1_800_000_000;
|
||||
const frame: StepReplayFrame = {
|
||||
index: 0, stepNumber: 1, title: '步骤 1: 点击设置', imageUrl: 'step.png',
|
||||
actionText: '点击设置', timestamp: start + 5
|
||||
};
|
||||
return {
|
||||
isVideoWindowOpen: signal(true),
|
||||
isVideoMinimized: signal(false),
|
||||
recordingPlaybackStatus: signal('ready'),
|
||||
playerMode: signal('video'),
|
||||
hasCurrentSessionStepFrames: signal(true),
|
||||
activeVideoTitle: signal('测试录像'),
|
||||
isVideoLoading: signal(false),
|
||||
activeVideoUrl: signal('/test.mp4'),
|
||||
activeVideoSegments: signal([{ url: '/test.mp4', start: 0, duration: 20, width: 400, height: 800 }]),
|
||||
currentSessionStepFrames: signal([frame]),
|
||||
currentSession: signal({ session_id: 'test', start_time: start }),
|
||||
currentSessionId: signal('test'),
|
||||
videoSeekRequest: signal(null),
|
||||
stepSeekRequest: signal(null),
|
||||
consumeVideoAutoplay: jasmine.createSpy('consumeVideoAutoplay').and.returnValue(false)
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
agentService = createAgentService();
|
||||
TestBed.configureTestingModule({
|
||||
imports: [FloatingVideoPlayerComponent],
|
||||
providers: [{ provide: AgentService, useValue: agentService }]
|
||||
});
|
||||
|
||||
fixture = TestBed.createComponent(FloatingVideoPlayerComponent);
|
||||
fixture.detectChanges();
|
||||
component = fixture.componentInstance;
|
||||
video = component.videoRef!.nativeElement;
|
||||
paused = true;
|
||||
seeking = false;
|
||||
time = 0;
|
||||
Object.defineProperties(video, {
|
||||
paused: { configurable: true, get: () => paused },
|
||||
seeking: { configurable: true, get: () => seeking },
|
||||
ended: { configurable: true, get: () => false },
|
||||
duration: { configurable: true, get: () => 20 },
|
||||
currentTime: {
|
||||
configurable: true, get: () => time,
|
||||
set: (value: number) => { time = value; seeking = true; }
|
||||
}
|
||||
});
|
||||
spyOn(video, 'pause').and.callFake(() => {
|
||||
paused = true;
|
||||
video.dispatchEvent(new Event('pause'));
|
||||
});
|
||||
play = spyOn(video, 'play').and.callFake(() => {
|
||||
paused = false;
|
||||
video.dispatchEvent(new Event('play'));
|
||||
return Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
function finishSeek(): void {
|
||||
seeking = false;
|
||||
video.dispatchEvent(new Event('seeked'));
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
function playButton(): HTMLButtonElement {
|
||||
return fixture.nativeElement.querySelector('.play-btn');
|
||||
}
|
||||
|
||||
it('shows a recorded step on the video timeline and seeks when clicked', () => {
|
||||
const marker = fixture.nativeElement.querySelector('.video-marker') as HTMLButtonElement;
|
||||
expect(marker).not.toBeNull();
|
||||
expect(marker.getAttribute('aria-label')).toContain('步骤 1');
|
||||
expect(marker.style.left).toBe('25%');
|
||||
|
||||
const seek = spyOn(fixture.componentInstance, 'seek');
|
||||
marker.click();
|
||||
expect(seek).toHaveBeenCalledOnceWith(5);
|
||||
});
|
||||
|
||||
it('resumes a playing video after a marker seek finishes and synchronizes the button', async () => {
|
||||
component.togglePlay();
|
||||
await Promise.resolve();
|
||||
play.calls.reset();
|
||||
fixture.nativeElement.querySelector('.video-marker').click();
|
||||
expect(time).toBe(5);
|
||||
expect(play).not.toHaveBeenCalled();
|
||||
finishSeek();
|
||||
await Promise.resolve();
|
||||
fixture.detectChanges();
|
||||
expect(paused).toBeFalse();
|
||||
expect(play).toHaveBeenCalledTimes(1);
|
||||
expect(playButton().title).toBe('暂停');
|
||||
});
|
||||
|
||||
it('keeps a paused video paused after a marker seek and resumes with one click', async () => {
|
||||
fixture.nativeElement.querySelector('.video-marker').click();
|
||||
finishSeek();
|
||||
expect(paused).toBeTrue();
|
||||
expect(play).not.toHaveBeenCalled();
|
||||
expect(playButton().title).toBe('播放');
|
||||
playButton().click();
|
||||
await Promise.resolve();
|
||||
fixture.detectChanges();
|
||||
expect(paused).toBeFalse();
|
||||
expect(playButton().title).toBe('暂停');
|
||||
});
|
||||
|
||||
it('preserves playing intent across rapid consecutive marker seeks', () => {
|
||||
component.togglePlay();
|
||||
component.seek(5);
|
||||
component.seek(8);
|
||||
play.calls.reset();
|
||||
finishSeek();
|
||||
expect(time).toBe(8);
|
||||
expect(play).toHaveBeenCalledTimes(1);
|
||||
expect(paused).toBeFalse();
|
||||
});
|
||||
|
||||
it('honors play and pause clicks while the destination is still seeking', () => {
|
||||
component.togglePlay();
|
||||
component.seek(5);
|
||||
play.calls.reset();
|
||||
fixture.detectChanges();
|
||||
expect(playButton().title).toBe('播放');
|
||||
playButton().click();
|
||||
fixture.detectChanges();
|
||||
expect(playButton().title).toBe('暂停');
|
||||
expect(play).not.toHaveBeenCalled();
|
||||
playButton().click();
|
||||
finishSeek();
|
||||
expect(paused).toBeTrue();
|
||||
expect(play).not.toHaveBeenCalled();
|
||||
expect(playButton().title).toBe('播放');
|
||||
});
|
||||
|
||||
it('waits for the destination segment to seek before resuming playback', () => {
|
||||
agentService.activeVideoSegments.set([
|
||||
{ url: '/test.mp4', start: 0, duration: 20, width: 400, height: 800 },
|
||||
{ url: '/second.mp4', start: 20, duration: 20, width: 400, height: 800 }
|
||||
]);
|
||||
fixture.detectChanges();
|
||||
component.togglePlay();
|
||||
component.seek(25);
|
||||
play.calls.reset();
|
||||
fixture.detectChanges();
|
||||
video.dispatchEvent(new Event('loadedmetadata'));
|
||||
expect(component.activeSegmentIndex()).toBe(1);
|
||||
expect(time).toBe(5);
|
||||
expect(play).not.toHaveBeenCalled();
|
||||
finishSeek();
|
||||
expect(play).toHaveBeenCalledTimes(1);
|
||||
expect(paused).toBeFalse();
|
||||
expect(component.currentTime()).toBe(25);
|
||||
});
|
||||
|
||||
it('keeps a paused video paused when jumping to another segment', () => {
|
||||
agentService.activeVideoSegments.set([
|
||||
{ url: '/test.mp4', start: 0, duration: 20, width: 400, height: 800 },
|
||||
{ url: '/second.mp4', start: 20, duration: 20, width: 400, height: 800 }
|
||||
]);
|
||||
fixture.detectChanges();
|
||||
component.seek(25);
|
||||
fixture.detectChanges();
|
||||
video.dispatchEvent(new Event('loadedmetadata'));
|
||||
finishSeek();
|
||||
expect(time).toBe(5);
|
||||
expect(paused).toBeTrue();
|
||||
expect(play).not.toHaveBeenCalled();
|
||||
expect(playButton().title).toBe('播放');
|
||||
});
|
||||
|
||||
it('resumes after seeking a recording without a segment manifest', () => {
|
||||
agentService.activeVideoSegments.set([]);
|
||||
fixture.detectChanges();
|
||||
video.dispatchEvent(new Event('loadedmetadata'));
|
||||
component.togglePlay();
|
||||
component.seek(5);
|
||||
play.calls.reset();
|
||||
finishSeek();
|
||||
expect(time).toBe(5);
|
||||
expect(component.currentTime()).toBe(5);
|
||||
expect(paused).toBeFalse();
|
||||
expect(play).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('resets a rejected play request so a single subsequent click can resume', async () => {
|
||||
play.and.callFake(() => {
|
||||
paused = false;
|
||||
video.dispatchEvent(new Event('play'));
|
||||
return Promise.reject(new DOMException('Interrupted', 'AbortError'));
|
||||
});
|
||||
component.seek(5, true);
|
||||
finishSeek();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
fixture.detectChanges();
|
||||
expect(paused).toBeTrue();
|
||||
expect(playButton().title).toBe('播放');
|
||||
play.and.callFake(() => {
|
||||
paused = false;
|
||||
video.dispatchEvent(new Event('play'));
|
||||
return Promise.resolve();
|
||||
});
|
||||
playButton().click();
|
||||
await Promise.resolve();
|
||||
fixture.detectChanges();
|
||||
expect(paused).toBeFalse();
|
||||
expect(playButton().title).toBe('暂停');
|
||||
});
|
||||
|
||||
it('ignores an older aborted play request after a newer seek resumes', async () => {
|
||||
let rejectOldPlay!: (reason: unknown) => void;
|
||||
play.and.returnValue(new Promise<void>((_, reject) => { rejectOldPlay = reject; }));
|
||||
component.togglePlay();
|
||||
paused = false;
|
||||
component.seek(5);
|
||||
play.and.callFake(() => { paused = false; return Promise.resolve(); });
|
||||
finishSeek();
|
||||
rejectOldPlay(new DOMException('Interrupted', 'AbortError'));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(paused).toBeFalse();
|
||||
expect(component.isPlaying()).toBeTrue();
|
||||
});
|
||||
});
|
||||
+113
-28
@@ -34,6 +34,7 @@ import { StepReplayFrame } from '../../core/models/stream.model';
|
||||
import { drawActionCoordinatesOnOverlay } from '../../utils/image-overlay.util';
|
||||
import { getActionIcon } from '../../utils/action-formatter.util';
|
||||
import { locateTimelineTime, sessionTimeToTimelineTime } from '../../utils/recording-timeline.util';
|
||||
import { buildVideoStepMarkers, VideoStepMarker } from '../../utils/video-step-markers.util';
|
||||
|
||||
@Component({
|
||||
selector: 'app-floating-video-player',
|
||||
@@ -70,6 +71,8 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
||||
private pendingLocalTime: number | null = null;
|
||||
private pendingAutoplay = false;
|
||||
private pendingAbsoluteSeek: number | null = null;
|
||||
private seekPlaybackIntent: boolean | null = null;
|
||||
private playRequestId = 0;
|
||||
|
||||
// Step Replay state signals
|
||||
public activeStepIndex = signal<number>(0);
|
||||
@@ -83,6 +86,12 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
||||
private stepTimer: any = null;
|
||||
|
||||
public stepFrames = computed(() => this.agentService.currentSessionStepFrames());
|
||||
public videoStepMarkers = computed(() => buildVideoStepMarkers(
|
||||
this.stepFrames(),
|
||||
this.agentService.currentSession()?.start_time,
|
||||
this.agentService.activeVideoSegments(),
|
||||
this.duration()
|
||||
));
|
||||
public totalStepFrames = computed(() => this.stepFrames().length);
|
||||
public currentStepFrame = computed<StepReplayFrame | null>(() => {
|
||||
const frames = this.stepFrames();
|
||||
@@ -131,6 +140,10 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
||||
this.currentTime.set(0);
|
||||
this.duration.set(segments.reduce((sum, segment) => sum + segment.duration, 0));
|
||||
this.isPlaying.set(false);
|
||||
this.pendingLocalTime = null;
|
||||
this.pendingAutoplay = false;
|
||||
this.seekPlaybackIntent = null;
|
||||
this.playRequestId++;
|
||||
},
|
||||
{ allowSignalWrites: true }
|
||||
);
|
||||
@@ -308,20 +321,19 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
||||
// that segment's metadata event apply pendingLocalTime.
|
||||
if (this.pendingLocalTime !== null) return;
|
||||
}
|
||||
if (this.pendingLocalTime !== null) {
|
||||
v.currentTime = Math.max(0, Math.min(v.duration || this.pendingLocalTime, this.pendingLocalTime));
|
||||
this.pendingLocalTime = null;
|
||||
}
|
||||
if (this.pendingAutoplay) {
|
||||
this.pendingAutoplay = false;
|
||||
v.play().catch(() => {});
|
||||
}
|
||||
if (this.agentService.consumeVideoAutoplay()) {
|
||||
const autoplay = this.agentService.consumeVideoAutoplay();
|
||||
if (autoplay) {
|
||||
this.isMuted.set(true);
|
||||
v.muted = true;
|
||||
v.play().catch(() => {
|
||||
this.isPlaying.set(false);
|
||||
});
|
||||
}
|
||||
const shouldPlay = this.pendingAutoplay || autoplay;
|
||||
this.pendingAutoplay = false;
|
||||
if (this.pendingLocalTime !== null) {
|
||||
const localTime = Math.max(0, Math.min(v.duration || this.pendingLocalTime, this.pendingLocalTime));
|
||||
this.pendingLocalTime = null;
|
||||
this.seekWithinVideo(localTime, shouldPlay);
|
||||
} else if (shouldPlay) {
|
||||
this.playVideo();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -339,14 +351,28 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
public onPlay(): void {
|
||||
this.isPlaying.set(true);
|
||||
this.isPlaying.set(!this.videoRef?.nativeElement.paused);
|
||||
}
|
||||
|
||||
public onPause(): void {
|
||||
this.isPlaying.set(false);
|
||||
}
|
||||
|
||||
public onSeeked(): void {
|
||||
const v = this.videoRef?.nativeElement;
|
||||
if (!v || v.seeking || this.pendingLocalTime !== null) return;
|
||||
const shouldPlay = this.seekPlaybackIntent;
|
||||
this.seekPlaybackIntent = null;
|
||||
this.onTimeUpdate();
|
||||
if (shouldPlay) {
|
||||
this.playVideo();
|
||||
} else {
|
||||
this.isPlaying.set(!v.paused && !v.ended);
|
||||
}
|
||||
}
|
||||
|
||||
public onEnded(): void {
|
||||
this.isPlaying.set(false);
|
||||
const segments = this.agentService.activeVideoSegments();
|
||||
if (segments.length && this.activeSegmentIndex() < segments.length - 1) {
|
||||
this.pendingLocalTime = 0;
|
||||
@@ -362,6 +388,10 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
public onError(): void {
|
||||
this.playRequestId++;
|
||||
this.pendingLocalTime = null;
|
||||
this.pendingAutoplay = false;
|
||||
this.seekPlaybackIntent = null;
|
||||
this.videoLoadError.set(true);
|
||||
this.isPlaying.set(false);
|
||||
}
|
||||
@@ -372,11 +402,52 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
||||
public togglePlay(): void {
|
||||
const v = this.videoRef?.nativeElement;
|
||||
if (!v) return;
|
||||
if (v.paused) {
|
||||
v.play().catch(() => {});
|
||||
} else {
|
||||
v.pause();
|
||||
if (this.pendingLocalTime !== null) {
|
||||
this.pendingAutoplay = !this.isPlaying();
|
||||
this.isPlaying.set(this.pendingAutoplay);
|
||||
return;
|
||||
}
|
||||
if (this.seekPlaybackIntent !== null) {
|
||||
this.seekPlaybackIntent = !this.isPlaying();
|
||||
this.isPlaying.set(this.seekPlaybackIntent);
|
||||
return;
|
||||
}
|
||||
if (v.paused) {
|
||||
this.playVideo();
|
||||
} else {
|
||||
this.playRequestId++;
|
||||
v.pause();
|
||||
this.isPlaying.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private playVideo(): void {
|
||||
const v = this.videoRef?.nativeElement;
|
||||
if (!v) return;
|
||||
const requestId = ++this.playRequestId;
|
||||
v.play().then(() => {
|
||||
if (requestId === this.playRequestId) this.isPlaying.set(!v.paused && !v.ended);
|
||||
}).catch(() => {
|
||||
// A rejected play request can leave paused=false without advancing frames.
|
||||
// Reset the native state too, so the next click really starts playback.
|
||||
if (requestId === this.playRequestId) {
|
||||
v.pause();
|
||||
this.isPlaying.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private seekWithinVideo(seconds: number, shouldPlay: boolean): void {
|
||||
const v = this.videoRef?.nativeElement;
|
||||
if (!v) return;
|
||||
this.playRequestId++;
|
||||
this.seekPlaybackIntent = shouldPlay;
|
||||
// Cancel any unfinished play request before seeking; resume only once the
|
||||
// browser has decoded the destination, including after a source switch.
|
||||
v.pause();
|
||||
this.isPlaying.set(false);
|
||||
v.currentTime = seconds;
|
||||
if (!v.seeking) this.onSeeked();
|
||||
}
|
||||
|
||||
public restart(): void {
|
||||
@@ -394,6 +465,19 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
||||
this.seek(sessionTimeToTimelineTime(segments, sessionSeconds), autoplay);
|
||||
}
|
||||
|
||||
public seekToVideoMarker(marker: VideoStepMarker): void {
|
||||
this.seek(marker.time);
|
||||
}
|
||||
|
||||
public videoMarkerLabel(marker: VideoStepMarker): string {
|
||||
const first = marker.steps[0];
|
||||
const count = marker.steps.length;
|
||||
const action = first.actionText || first.title;
|
||||
return count > 1
|
||||
? `步骤 ${first.stepNumber} 等 ${count} 步 · ${this.formatTime(marker.time)} · ${action}`
|
||||
: `步骤 ${first.stepNumber} · ${this.formatTime(marker.time)} · ${action}`;
|
||||
}
|
||||
|
||||
/** Seek on the back-to-back playlist axis used by the scrubber. */
|
||||
public seek(seconds: number, autoplay = false): void {
|
||||
const v = this.videoRef?.nativeElement;
|
||||
@@ -401,19 +485,19 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
||||
const target = Math.max(0, Math.min(this.duration(), seconds));
|
||||
const segments = this.agentService.activeVideoSegments();
|
||||
const location = locateTimelineTime(segments, target);
|
||||
const shouldPlay = autoplay || this.pendingAutoplay || (this.seekPlaybackIntent ?? !v.paused);
|
||||
if (!location) {
|
||||
v.currentTime = target;
|
||||
if (autoplay) v.play().catch(() => {});
|
||||
return;
|
||||
}
|
||||
const { index, localTime } = location;
|
||||
if (index === this.activeSegmentIndex()) {
|
||||
v.currentTime = localTime;
|
||||
if (autoplay) v.play().catch(() => {});
|
||||
this.seekWithinVideo(target, shouldPlay);
|
||||
} else if (location.index === this.activeSegmentIndex() && this.pendingLocalTime === null) {
|
||||
this.seekWithinVideo(location.localTime, shouldPlay);
|
||||
} else {
|
||||
this.pendingLocalTime = localTime;
|
||||
this.pendingAutoplay = autoplay || !v.paused;
|
||||
this.activeSegmentIndex.set(index);
|
||||
this.playRequestId++;
|
||||
this.seekPlaybackIntent = null;
|
||||
v.pause();
|
||||
this.isPlaying.set(false);
|
||||
this.pendingLocalTime = location.localTime;
|
||||
this.pendingAutoplay = shouldPlay;
|
||||
this.activeSegmentIndex.set(location.index);
|
||||
}
|
||||
this.currentTime.set(target);
|
||||
}
|
||||
@@ -621,6 +705,7 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
public ngOnDestroy(): void {
|
||||
this.playRequestId++;
|
||||
if (this.stepTimer) {
|
||||
clearTimeout(this.stepTimer);
|
||||
this.stepTimer = null;
|
||||
|
||||
+41
-4
@@ -8,6 +8,14 @@ interface Capabilities {
|
||||
recording_enabled: boolean;
|
||||
outputter_enabled: boolean;
|
||||
step_summarizer_enabled: boolean;
|
||||
screenshot_limit_enabled: boolean;
|
||||
screenshot_max_long_edge: number;
|
||||
task_timeout_seconds: number;
|
||||
observation_timeout_seconds: number;
|
||||
observation_max_attempts: number;
|
||||
device_command_timeout_seconds: number;
|
||||
action_failure_attempts: number;
|
||||
worker_cleanup_timeout_seconds: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -24,7 +32,21 @@ interface Capabilities {
|
||||
<span>最终报告生成<small>根据执行记录生成任务结果报告。</small></span></label>
|
||||
<label><input type="checkbox" role="switch" [(ngModel)]="config.step_summarizer_enabled" [disabled]="busy() || loading() || !loaded()" />
|
||||
<span>步骤摘要压缩<small>Flash 模式下在后台生成摘要,减少长任务的上下文占用。</small></span></label>
|
||||
<button type="button" (click)="save()" [disabled]="busy() || loading() || !loaded()">{{ busy() ? '正在保存…' : '保存功能设置' }}</button>
|
||||
<label><input type="checkbox" role="switch" [(ngModel)]="config.screenshot_limit_enabled" [disabled]="busy() || loading() || !loaded()" />
|
||||
<span>限制截图分辨率<small>开启时长边最多 {{ config.screenshot_max_long_edge }} 像素;关闭后使用设备原始分辨率。JSON 配置可覆盖开关和上限。</small></span></label>
|
||||
<details class="execution-settings">
|
||||
<summary>执行保护</summary>
|
||||
<p>限制故障等待与反复失败;保存后对新任务生效,任务 JSON 可单独覆盖。</p>
|
||||
<div class="number-fields">
|
||||
<label>任务总超时(秒)<input type="number" min="30" max="86400" step="1" [(ngModel)]="config.task_timeout_seconds" [disabled]="busy() || !loaded()" /><small>不包含排队和用户暂停时间。</small></label>
|
||||
<label>屏幕观测超时(秒)<input type="number" min="1" max="300" [(ngModel)]="config.observation_timeout_seconds" [disabled]="busy() || !loaded()" /></label>
|
||||
<label>观测恢复最多尝试次数<input type="number" min="1" max="5" step="1" [(ngModel)]="config.observation_max_attempts" [disabled]="busy() || !loaded()" /></label>
|
||||
<label>ADB 命令超时(秒)<input type="number" min="1" max="60" [(ngModel)]="config.device_command_timeout_seconds" [disabled]="busy() || !loaded()" /></label>
|
||||
<label>连续动作失败次数<input type="number" min="1" max="10" step="1" [(ngModel)]="config.action_failure_attempts" [disabled]="busy() || !loaded()" /><small>先重新观察,再让兜底模型处理当前步骤。</small></label>
|
||||
<label>任务收尾超时(秒)<input type="number" min="1" max="300" [(ngModel)]="config.worker_cleanup_timeout_seconds" [disabled]="busy() || !loaded()" /><small>已有最终结果但进程未退出时,回收进程以释放队列。</small></label>
|
||||
</div>
|
||||
</details>
|
||||
<button type="button" (click)="save()" [disabled]="busy() || loading() || !loaded() || !valid()">{{ busy() ? '正在保存…' : '保存功能设置' }}</button>
|
||||
@if (message()) { <p role="status">{{ message() }}</p> }
|
||||
@if (error()) { <p role="alert" class="error">{{ error() }}</p> }
|
||||
</section>
|
||||
@@ -34,12 +56,18 @@ interface Capabilities {
|
||||
label { display: flex; gap: 12px; align-items: flex-start; padding: 14px 0; cursor: pointer; }
|
||||
input { width: 20px; height: 20px; accent-color: #0284c7; flex-shrink: 0; }
|
||||
small { display: block; margin-top: 4px; } button { padding: 10px 18px; border-radius: 10px; border: 1px solid #38bdf877; background: #0284c7; color: white; font: inherit; cursor: pointer; }
|
||||
button:disabled { opacity: .5; cursor: default; } .error { color: #be123c; }`]
|
||||
button:disabled { opacity: .5; cursor: default; } .error { color: #be123c; }
|
||||
.execution-settings { margin: 12px 0 20px; padding: 12px 14px; border: 1px solid #cbd5e1; border-radius: 10px; background: #f8fafc; }
|
||||
summary { cursor: pointer; font-weight: 600; color: #334155; }
|
||||
.number-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 16px; }
|
||||
.number-fields label { display: block; padding: 0; color: #334155; }
|
||||
.number-fields input { box-sizing: border-box; display: block; width: 100%; height: auto; margin-top: 8px; padding: 9px; border: 1px solid #cbd5e1; border-radius: 8px; font: inherit; }
|
||||
@media(max-width: 480px) { .number-fields { grid-template-columns: 1fr; } }`]
|
||||
})
|
||||
export class CapabilitySettingsComponent implements OnInit {
|
||||
changed = output<Capabilities>();
|
||||
private http = inject(HttpClient);
|
||||
config: Capabilities = { video_enabled: false, recording_enabled: false, outputter_enabled: true, step_summarizer_enabled: true };
|
||||
config: Capabilities = { video_enabled: false, recording_enabled: false, outputter_enabled: true, step_summarizer_enabled: true, screenshot_limit_enabled: true, screenshot_max_long_edge: 2048, task_timeout_seconds: 1800, observation_timeout_seconds: 60, observation_max_attempts: 3, device_command_timeout_seconds: 10, action_failure_attempts: 3, worker_cleanup_timeout_seconds: 30 };
|
||||
busy = signal(false); loading = signal(true); loaded = signal(false); message = signal(''); error = signal('');
|
||||
ngOnInit(): void {
|
||||
this.http.get<Capabilities>('/api/system/capability-settings').pipe(finalize(() => this.loading.set(false))).subscribe({
|
||||
@@ -47,11 +75,20 @@ export class CapabilitySettingsComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
save(): void {
|
||||
if (this.busy() || this.loading() || !this.loaded()) return;
|
||||
if (this.busy() || this.loading() || !this.loaded() || !this.valid()) return;
|
||||
this.busy.set(true); this.message.set(''); this.error.set('');
|
||||
this.http.post<Capabilities>('/api/system/capability-settings', this.config).pipe(finalize(() => this.busy.set(false))).subscribe({
|
||||
next: config => { this.config = config; this.changed.emit(config); this.message.set('功能设置已保存,新任务将使用此配置。'); },
|
||||
error: () => this.error.set('保存失败,请检查服务器连接和 .env 文件权限。')
|
||||
});
|
||||
}
|
||||
valid(): boolean {
|
||||
const c = this.config;
|
||||
return Number.isInteger(c.task_timeout_seconds) && c.task_timeout_seconds >= 30 && c.task_timeout_seconds <= 86400
|
||||
&& c.observation_timeout_seconds > 0 && c.observation_timeout_seconds <= 300
|
||||
&& Number.isInteger(c.observation_max_attempts) && c.observation_max_attempts >= 1 && c.observation_max_attempts <= 5
|
||||
&& c.device_command_timeout_seconds > 0 && c.device_command_timeout_seconds <= 60
|
||||
&& Number.isInteger(c.action_failure_attempts) && c.action_failure_attempts >= 1 && c.action_failure_attempts <= 10
|
||||
&& c.worker_cleanup_timeout_seconds > 0 && c.worker_cleanup_timeout_seconds <= 300;
|
||||
}
|
||||
}
|
||||
+63
-2
@@ -8,6 +8,8 @@ describe('持久化模型与功能设置', () => {
|
||||
let http: HttpTestingController;
|
||||
const config = { base_url: 'https://example.test/v1', model: 'mimo-v2.6-flash',
|
||||
api_key_configured: true, timeout_seconds: 300, max_retries: 5, retry_interval_seconds: 10, reasoning_effort: 'high',
|
||||
rate_limit_retry_seconds: 60, tool_fallback_model: 'gpt-6-luna', tool_fallback_reasoning_effort: 'high',
|
||||
tool_fallback_max_retries: 3, tool_fallback_no_progress_attempts: 3,
|
||||
model_presets: ['gpt-5.6-luna', 'gpt-6-luna', 'mimo-v2.6-flash', 'mimo-v2.6-pro'], env_path: '/app/.env' };
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
|
||||
@@ -31,6 +33,9 @@ describe('持久化模型与功能设置', () => {
|
||||
expect(request.request.body.timeout_seconds).toBe(300);
|
||||
expect(request.request.body.max_retries).toBe(5);
|
||||
expect(request.request.body.retry_interval_seconds).toBe(10);
|
||||
expect(request.request.body.rate_limit_retry_seconds).toBe(60);
|
||||
expect(request.request.body.tool_fallback_model).toBe('gpt-6-luna');
|
||||
expect(request.request.body.tool_fallback_no_progress_attempts).toBe(3);
|
||||
expect(request.request.body.api_key).toBeUndefined();
|
||||
request.flush({ ...config, model: 'another-model' });
|
||||
expect(component.busy()).toBeNull();
|
||||
@@ -88,6 +93,29 @@ describe('持久化模型与功能设置', () => {
|
||||
expect(reopened.nativeElement.querySelector('#model-reasoning').value).toBe('max');
|
||||
});
|
||||
|
||||
it('高级稳定性设置可编辑并保存', async () => {
|
||||
const fixture = TestBed.createComponent(ModelSettingsComponent);
|
||||
fixture.detectChanges();
|
||||
http.expectOne('/api/system/model-settings').flush(config);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
const root: HTMLElement = fixture.nativeElement;
|
||||
root.querySelector<HTMLDetailsElement>('.recovery-settings')!.open = true;
|
||||
const fallback = root.querySelector<HTMLInputElement>('input[name="toolFallbackModel"]')!;
|
||||
fallback.value = 'custom/fallback';
|
||||
fallback.dispatchEvent(new Event('input'));
|
||||
const wait = root.querySelector<HTMLInputElement>('input[name="rateLimitRetry"]')!;
|
||||
wait.value = '90';
|
||||
wait.dispatchEvent(new Event('input'));
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
root.querySelector<HTMLFormElement>('form')!.dispatchEvent(new Event('submit', { cancelable: true }));
|
||||
const request = http.expectOne('/api/system/model-settings');
|
||||
expect(request.request.body.tool_fallback_model).toBe('custom/fallback');
|
||||
expect(request.request.body.rate_limit_retry_seconds).toBe(90);
|
||||
request.flush({ ...config, tool_fallback_model: 'custom/fallback', rate_limit_retry_seconds: 90 });
|
||||
});
|
||||
|
||||
it('连接测试使用当前输入而不写入保存接口', () => {
|
||||
const fixture = TestBed.createComponent(ModelSettingsComponent);
|
||||
fixture.detectChanges();
|
||||
@@ -110,17 +138,19 @@ describe('持久化模型与功能设置', () => {
|
||||
});
|
||||
|
||||
it('录屏与视频分析独立保存,重新打开后保留各自状态', () => {
|
||||
const capabilities = { video_enabled: true, recording_enabled: false, outputter_enabled: true, step_summarizer_enabled: false };
|
||||
const capabilities = { video_enabled: true, recording_enabled: false, outputter_enabled: true, step_summarizer_enabled: false, screenshot_limit_enabled: true, screenshot_max_long_edge: 2048, task_timeout_seconds: 1800, observation_timeout_seconds: 60, observation_max_attempts: 3, device_command_timeout_seconds: 10, action_failure_attempts: 3, worker_cleanup_timeout_seconds: 30 };
|
||||
const fixture = TestBed.createComponent(CapabilitySettingsComponent);
|
||||
fixture.detectChanges();
|
||||
http.expectOne('/api/system/capability-settings').flush(capabilities);
|
||||
fixture.componentInstance.config.video_enabled = false;
|
||||
fixture.componentInstance.config.recording_enabled = true;
|
||||
fixture.componentInstance.config.screenshot_limit_enabled = false;
|
||||
fixture.componentInstance.save();
|
||||
const request = http.expectOne('/api/system/capability-settings');
|
||||
expect(request.request.body.video_enabled).toBeFalse();
|
||||
expect(request.request.body.recording_enabled).toBeTrue();
|
||||
const saved = { ...capabilities, video_enabled: false, recording_enabled: true };
|
||||
expect(request.request.body.screenshot_limit_enabled).toBeFalse();
|
||||
const saved = { ...capabilities, video_enabled: false, recording_enabled: true, screenshot_limit_enabled: false };
|
||||
request.flush(saved);
|
||||
fixture.destroy();
|
||||
const reopened = TestBed.createComponent(CapabilitySettingsComponent);
|
||||
@@ -128,5 +158,36 @@ describe('持久化模型与功能设置', () => {
|
||||
http.expectOne('/api/system/capability-settings').flush(saved);
|
||||
expect(reopened.componentInstance.config.video_enabled).toBeFalse();
|
||||
expect(reopened.componentInstance.config.recording_enabled).toBeTrue();
|
||||
expect(reopened.componentInstance.config.screenshot_limit_enabled).toBeFalse();
|
||||
});
|
||||
|
||||
it('执行保护默认折叠,修改时限可保存且拒绝无效数值', async () => {
|
||||
const capabilities = { video_enabled: false, recording_enabled: false, outputter_enabled: true, step_summarizer_enabled: true, screenshot_limit_enabled: true, screenshot_max_long_edge: 2048, task_timeout_seconds: 1800, observation_timeout_seconds: 60, observation_max_attempts: 3, device_command_timeout_seconds: 10, action_failure_attempts: 3, worker_cleanup_timeout_seconds: 30 };
|
||||
const fixture = TestBed.createComponent(CapabilitySettingsComponent);
|
||||
fixture.detectChanges();
|
||||
http.expectOne('/api/system/capability-settings').flush(capabilities);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
const root: HTMLElement = fixture.nativeElement;
|
||||
expect(root.querySelector<HTMLDetailsElement>('.execution-settings')!.open).toBeFalse();
|
||||
root.querySelector<HTMLElement>('summary')!.click();
|
||||
const timeout = root.querySelector<HTMLInputElement>('input[type="number"]')!;
|
||||
timeout.value = '3600';
|
||||
timeout.dispatchEvent(new Event('input'));
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
root.querySelector<HTMLButtonElement>('button')!.click();
|
||||
const request = http.expectOne('/api/system/capability-settings');
|
||||
expect(request.request.body.task_timeout_seconds).toBe(3600);
|
||||
expect(request.request.body.observation_timeout_seconds).toBe(60);
|
||||
request.flush({ ...capabilities, task_timeout_seconds: 3600 });
|
||||
expect(fixture.componentInstance.message()).toContain('已保存');
|
||||
timeout.value = '0';
|
||||
timeout.dispatchEvent(new Event('input'));
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
expect(root.querySelector<HTMLButtonElement>('button')!.disabled).toBeTrue();
|
||||
fixture.componentInstance.save();
|
||||
http.expectNone('/api/system/capability-settings');
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,11 @@ interface ModelSettings {
|
||||
timeout_seconds: number;
|
||||
max_retries: number;
|
||||
retry_interval_seconds: number;
|
||||
rate_limit_retry_seconds: number;
|
||||
tool_fallback_model: string;
|
||||
tool_fallback_reasoning_effort: string;
|
||||
tool_fallback_max_retries: number;
|
||||
tool_fallback_no_progress_attempts: number;
|
||||
reasoning_effort: string;
|
||||
model_presets: string[];
|
||||
env_path: string;
|
||||
@@ -56,6 +61,30 @@ interface ModelSettings {
|
||||
[(ngModel)]="retries" [disabled]="busy() || loading()" /></label>
|
||||
<label>重试间隔(秒)<input name="retryInterval" type="number" min="0" max="300" required [(ngModel)]="retryInterval" [disabled]="busy() || loading()" /></label>
|
||||
</div>
|
||||
<details class="recovery-settings">
|
||||
<summary>稳定性与兜底模型</summary>
|
||||
<div class="recovery-content">
|
||||
<label>429 等待重试(秒)<input name="rateLimitRetry" type="number" min="0" max="3600" required
|
||||
[(ngModel)]="rateLimitRetry" [disabled]="busy() || loading()" /></label>
|
||||
<label>兜底模型名称<input name="toolFallbackModel" type="text" required autocomplete="off"
|
||||
autocapitalize="off" spellcheck="false" [(ngModel)]="toolFallbackModel"
|
||||
[disabled]="busy() || loading()" /></label>
|
||||
<label>兜底思考强度<select name="toolFallbackReasoning" [(ngModel)]="toolFallbackReasoning"
|
||||
[disabled]="busy() || loading()">
|
||||
<option value="default">由模型默认</option><option value="none">关闭(none)</option>
|
||||
<option value="minimal">最低(minimal)</option><option value="low">低(low)</option>
|
||||
<option value="medium">中(medium)</option><option value="high">高(high)</option>
|
||||
<option value="xhigh">极高(xhigh)</option><option value="max">最高(max)</option>
|
||||
</select></label>
|
||||
<div class="number-fields">
|
||||
<label>兜底请求重试次数<input name="toolFallbackRetries" type="number" min="0" max="10" required
|
||||
[(ngModel)]="toolFallbackRetries" [disabled]="busy() || loading()" /></label>
|
||||
<label>兜底无效调用上限<input name="toolFallbackNoProgress" type="number" min="1" max="10" required
|
||||
[(ngModel)]="toolFallbackNoProgress" [disabled]="busy() || loading()" /></label>
|
||||
</div>
|
||||
<small>首选模型连续 3 次未形成有效工具调用后,本步骤使用兜底模型;下一步恢复首选模型。</small>
|
||||
</div>
|
||||
</details>
|
||||
<div class="actions">
|
||||
<button type="submit" [disabled]="busy() || loading() || !valid()">{{ busy() === 'save' ? '正在保存…' : '保存配置' }}</button>
|
||||
<button type="button" (click)="test()" [disabled]="busy() || loading() || !valid()">
|
||||
@@ -77,6 +106,10 @@ interface ModelSettings {
|
||||
input:focus, select:focus { outline: 2px solid #38bdf8; }
|
||||
.number-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 8px; }
|
||||
.number-fields input { margin-top: 8px; }
|
||||
.recovery-settings { border: 1px solid #cbd5e1; border-radius: 10px; background: #f8fafc; padding: 10px 14px; }
|
||||
.recovery-settings summary { cursor: pointer; font-weight: 600; color: #334155; }
|
||||
.recovery-content { display: grid; gap: 10px; padding-top: 12px; }
|
||||
.recovery-content label input, .recovery-content label select { margin-top: 8px; }
|
||||
.actions { display: flex; gap: 12px; margin-top: 8px; }
|
||||
button { padding: 11px 20px; border-radius: 10px; border: 1px solid #38bdf877; background: #0284c7; color: white; cursor: pointer; font: inherit; }
|
||||
button:disabled { opacity: .5; cursor: default; } .success { color: #047857; } .error { color: #be123c; }
|
||||
@@ -93,6 +126,8 @@ export class ModelSettingsComponent implements OnInit {
|
||||
saved = output<void>();
|
||||
modelPresets: string[] = [];
|
||||
baseUrl = ''; model = ''; apiKey = ''; timeout = 300; retries = 5; retryInterval = 10; reasoning = 'high';
|
||||
rateLimitRetry = 60; toolFallbackModel = 'gpt-6-luna'; toolFallbackReasoning = 'high';
|
||||
toolFallbackRetries = 3; toolFallbackNoProgress = 3;
|
||||
busy = signal<'save' | 'test' | null>(null);
|
||||
loading = signal(true); keyConfigured = signal(false); envPath = signal('');
|
||||
message = signal(''); error = signal('');
|
||||
@@ -105,16 +140,27 @@ export class ModelSettingsComponent implements OnInit {
|
||||
}
|
||||
valid(): boolean {
|
||||
return !!this.baseUrl.trim() && !!this.model.trim() && (!!this.apiKey.trim() || this.keyConfigured())
|
||||
&& this.timeout > 0 && this.timeout <= 3600 && Number.isInteger(this.retries) && this.retries >= 0 && this.retries <= 10 && this.retryInterval >= 0 && this.retryInterval <= 300;
|
||||
&& this.timeout > 0 && this.timeout <= 3600 && Number.isInteger(this.retries) && this.retries >= 0 && this.retries <= 10 && this.retryInterval >= 0 && this.retryInterval <= 300
|
||||
&& this.rateLimitRetry >= 0 && this.rateLimitRetry <= 3600 && !!this.toolFallbackModel.trim()
|
||||
&& Number.isInteger(this.toolFallbackRetries) && this.toolFallbackRetries >= 0 && this.toolFallbackRetries <= 10
|
||||
&& Number.isInteger(this.toolFallbackNoProgress) && this.toolFallbackNoProgress >= 1 && this.toolFallbackNoProgress <= 10;
|
||||
}
|
||||
private payload() {
|
||||
return { base_url: this.baseUrl.trim(), model: this.model.trim(), api_key: this.apiKey.trim() || undefined,
|
||||
timeout_seconds: this.timeout, max_retries: this.retries, retry_interval_seconds: this.retryInterval, reasoning_effort: this.reasoning };
|
||||
timeout_seconds: this.timeout, max_retries: this.retries, retry_interval_seconds: this.retryInterval, reasoning_effort: this.reasoning,
|
||||
rate_limit_retry_seconds: this.rateLimitRetry, tool_fallback_model: this.toolFallbackModel.trim(),
|
||||
tool_fallback_reasoning_effort: this.toolFallbackReasoning, tool_fallback_max_retries: this.toolFallbackRetries,
|
||||
tool_fallback_no_progress_attempts: this.toolFallbackNoProgress };
|
||||
}
|
||||
private apply(config: ModelSettings): void {
|
||||
this.baseUrl = config.base_url; this.model = config.model; this.timeout = config.timeout_seconds;
|
||||
this.modelPresets = config.model_presets;
|
||||
this.retries = config.max_retries; this.retryInterval = config.retry_interval_seconds ?? 10; this.reasoning = config.reasoning_effort ?? 'high'; this.keyConfigured.set(config.api_key_configured); this.envPath.set(config.env_path);
|
||||
this.rateLimitRetry = config.rate_limit_retry_seconds ?? 60;
|
||||
this.toolFallbackModel = config.tool_fallback_model ?? 'gpt-6-luna';
|
||||
this.toolFallbackReasoning = config.tool_fallback_reasoning_effort ?? 'high';
|
||||
this.toolFallbackRetries = config.tool_fallback_max_retries ?? 3;
|
||||
this.toolFallbackNoProgress = config.tool_fallback_no_progress_attempts ?? 3;
|
||||
}
|
||||
save(): void {
|
||||
if (this.busy() || !this.valid()) return;
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { StepReplayFrame } from '../core/models/stream.model';
|
||||
import { TimelineSegment } from './recording-timeline.util';
|
||||
import { buildVideoStepMarkers } from './video-step-markers.util';
|
||||
|
||||
describe('buildVideoStepMarkers', () => {
|
||||
const start = 1_800_000_000;
|
||||
const segments: TimelineSegment[] = [
|
||||
{ start: 0, duration: 10, offset_ms: 1000, duration_ms: 10000 },
|
||||
{ start: 10, duration: 10, offset_ms: 13000, duration_ms: 10000 }
|
||||
];
|
||||
const frame = (stepNumber: number, seconds: number, status = 'dispatched'): StepReplayFrame => ({
|
||||
index: stepNumber - 1,
|
||||
stepNumber,
|
||||
title: `步骤 ${stepNumber}`,
|
||||
imageUrl: 'step.png',
|
||||
timestamp: start + seconds,
|
||||
status
|
||||
});
|
||||
|
||||
it('places markers on the playable timeline across recording restart gaps', () => {
|
||||
const markers = buildVideoStepMarkers([frame(1, 4), frame(2, 15)], start, segments, 20);
|
||||
expect(markers.map((marker) => marker.time)).toEqual([3, 12]);
|
||||
expect(markers.map((marker) => marker.percent)).toEqual([15, 60]);
|
||||
});
|
||||
|
||||
it('snaps a step in a recording gap to the following segment', () => {
|
||||
const markers = buildVideoStepMarkers([frame(1, 12)], start, segments, 20);
|
||||
expect(markers[0].time).toBe(10);
|
||||
});
|
||||
|
||||
it('omits steps outside the actual recording and without timestamps', () => {
|
||||
const missing = { ...frame(4, 5), timestamp: undefined };
|
||||
const markers = buildVideoStepMarkers(
|
||||
[frame(1, 0), frame(2, 2), frame(3, 24), missing], start, segments, 20
|
||||
);
|
||||
expect(markers.map((marker) => marker.steps[0].stepNumber)).toEqual([2]);
|
||||
});
|
||||
|
||||
it('groups crowded steps and keeps failure information', () => {
|
||||
const markers = buildVideoStepMarkers(
|
||||
[frame(1, 4), frame(2, 4.1, 'failed'), frame(3, 8)], start, segments, 20
|
||||
);
|
||||
expect(markers.length).toBe(2);
|
||||
expect(markers[0].steps.map((step) => step.stepNumber)).toEqual([1, 2]);
|
||||
expect(markers[0].failed).toBeTrue();
|
||||
});
|
||||
|
||||
it('uses session seconds directly for a legacy playlist or single video', () => {
|
||||
expect(buildVideoStepMarkers([frame(1, 6)], start, [{ start: 0, duration: 20 }], 20)[0].time).toBe(6);
|
||||
expect(buildVideoStepMarkers([frame(1, 6)], start, [], 20)[0].time).toBe(6);
|
||||
expect(buildVideoStepMarkers([frame(1, 6)], undefined, [], 20)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { StepReplayFrame } from '../core/models/stream.model';
|
||||
import { hasSessionOffsets, sessionTimeToTimelineTime, TimelineSegment } from './recording-timeline.util';
|
||||
|
||||
export interface VideoStepMarker {
|
||||
time: number;
|
||||
percent: number;
|
||||
steps: StepReplayFrame[];
|
||||
failed: boolean;
|
||||
}
|
||||
|
||||
/** Place recorded action steps on the video's gap-free playback timeline. */
|
||||
export function buildVideoStepMarkers(
|
||||
frames: readonly StepReplayFrame[],
|
||||
sessionStart: number | undefined,
|
||||
segments: readonly TimelineSegment[],
|
||||
duration: number
|
||||
): VideoStepMarker[] {
|
||||
const start = Number(sessionStart);
|
||||
if (!Number.isFinite(start) || !Number.isFinite(duration) || duration <= 0) return [];
|
||||
|
||||
const hasOffsets = hasSessionOffsets(segments);
|
||||
const firstOffset = hasOffsets ? (segments[0].offset_ms as number) / 1000 : 0;
|
||||
const lastSegment = segments[segments.length - 1];
|
||||
const lastOffset = hasOffsets
|
||||
? ((lastSegment.offset_ms as number) + (lastSegment.duration_ms as number)) / 1000
|
||||
: duration;
|
||||
|
||||
const positioned = frames.flatMap((frame) => {
|
||||
const timestamp = Number(frame.timestamp);
|
||||
if (frame.timestamp == null || !Number.isFinite(timestamp)) return [];
|
||||
const sessionTime = timestamp - start;
|
||||
// Do not create misleading ticks at the start/end for steps outside the recording.
|
||||
if (sessionTime < firstOffset || sessionTime > lastOffset) return [];
|
||||
const time = sessionTimeToTimelineTime(segments, sessionTime);
|
||||
if (time < 0 || time > duration) return [];
|
||||
return [{ time, frame }];
|
||||
}).sort((a, b) => a.time - b.time);
|
||||
|
||||
const markers: VideoStepMarker[] = [];
|
||||
// Nearby actions share one visible tick on a narrow player timeline.
|
||||
const minGap = duration * 0.02;
|
||||
for (const { time, frame } of positioned) {
|
||||
const previous = markers[markers.length - 1];
|
||||
if (previous && time - previous.time < minGap) {
|
||||
previous.steps.push(frame);
|
||||
previous.failed ||= frame.status === 'failed';
|
||||
} else {
|
||||
markers.push({ time, percent: (time / duration) * 100, steps: [frame], failed: frame.status === 'failed' });
|
||||
}
|
||||
}
|
||||
return markers;
|
||||
}
|
||||
+118
-28
@@ -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}"
|
||||
@@ -1071,6 +1091,9 @@ class FlashRunner:
|
||||
pending_notices: list[str] = []
|
||||
no_tool_attempts = 0
|
||||
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)
|
||||
@@ -1080,6 +1103,10 @@ class FlashRunner:
|
||||
on_resume=getattr(self.ctx, "on_task_resume", None),
|
||||
)
|
||||
if resumed:
|
||||
using_tool_fallback = False
|
||||
no_tool_attempts = 0
|
||||
action_failure_attempts = 0
|
||||
self._llm = llm
|
||||
(
|
||||
shot_path,
|
||||
current_pre_screenshot_bytes,
|
||||
@@ -1131,14 +1158,18 @@ class FlashRunner:
|
||||
|
||||
if isinstance(control, TaskControl):
|
||||
interrupted, response = await control.run_interruptibly(
|
||||
self._invoke_model(llm, current_tools, messages),
|
||||
self._invoke_model(
|
||||
fallback_llm if using_tool_fallback else llm, current_tools, messages
|
||||
),
|
||||
on_pause=getattr(self.ctx, "on_task_pause", None),
|
||||
on_resume=getattr(self.ctx, "on_task_resume", None),
|
||||
)
|
||||
else:
|
||||
interrupted, response = (
|
||||
False,
|
||||
await self._invoke_model(llm, current_tools, messages),
|
||||
await self._invoke_model(
|
||||
fallback_llm if using_tool_fallback else llm, current_tools, messages
|
||||
),
|
||||
)
|
||||
|
||||
if interrupted or (
|
||||
@@ -1146,6 +1177,10 @@ class FlashRunner:
|
||||
):
|
||||
# Do not dispatch a stale action or final answer from a prompt
|
||||
# assembled before the instruction arrived.
|
||||
using_tool_fallback = False
|
||||
no_tool_attempts = 0
|
||||
action_failure_attempts = 0
|
||||
self._llm = llm
|
||||
(
|
||||
shot_path,
|
||||
current_pre_screenshot_bytes,
|
||||
@@ -1186,6 +1221,9 @@ class FlashRunner:
|
||||
|
||||
tool_calls = self._resolve_tool_calls(response, raw_text)
|
||||
if isinstance(control, TaskControl) and has_pending_instruction(control.directory):
|
||||
using_tool_fallback = False
|
||||
no_tool_attempts = 0
|
||||
self._llm = llm
|
||||
(
|
||||
shot_path,
|
||||
current_pre_screenshot_bytes,
|
||||
@@ -1207,15 +1245,28 @@ class FlashRunner:
|
||||
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
|
||||
active_limit = (
|
||||
settings.ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS
|
||||
if using_tool_fallback
|
||||
else no_tool_limit
|
||||
)
|
||||
if no_tool_attempts >= active_limit:
|
||||
if using_tool_fallback:
|
||||
final_report = {
|
||||
"status": "failed",
|
||||
"explanation": (
|
||||
f"首选模型连续 {no_tool_limit} 次、兜底模型连续 "
|
||||
f"{no_tool_attempts} 次未调用工具,已停止任务;"
|
||||
"未执行新的设备动作。"
|
||||
),
|
||||
}
|
||||
break
|
||||
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)
|
||||
# Keep the observation and any injected user instruction, but
|
||||
# do not feed the model its own unproductive prose on retry.
|
||||
@@ -1224,6 +1275,10 @@ class FlashRunner:
|
||||
continue
|
||||
|
||||
no_tool_attempts = 0
|
||||
if using_tool_fallback:
|
||||
# A tool-producing turn completes the fallback's scope. The next
|
||||
# observation goes back to the configured primary model.
|
||||
self._llm = fallback_llm
|
||||
# Process tool calls
|
||||
turn = _TurnRecord()
|
||||
(
|
||||
@@ -1244,6 +1299,41 @@ class FlashRunner:
|
||||
injected=injected,
|
||||
native_text=native_text,
|
||||
)
|
||||
if using_tool_fallback and any(status == "success" for _, status, _ in turn.actions):
|
||||
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:
|
||||
|
||||
@@ -508,6 +508,9 @@ class OperatorNode:
|
||||
plan_gate_bounced = False
|
||||
no_progress_attempts = 0
|
||||
no_progress_limit = settings.ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS
|
||||
fallback_limit = settings.ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS
|
||||
active_llm = base_llm
|
||||
using_tool_fallback = False
|
||||
|
||||
for iteration in range(max_iterations):
|
||||
control = getattr(self.ctx, "task_control", None)
|
||||
@@ -532,7 +535,7 @@ class OperatorNode:
|
||||
)
|
||||
)
|
||||
|
||||
bound_llm = base_llm.bind_tools(tools=traced_tools)
|
||||
bound_llm = active_llm.bind_tools(tools=traced_tools)
|
||||
interrupted, response = await self._await_or_pause(
|
||||
invoke_llm_with_timeout_message(acomplete(bound_llm, current_messages))
|
||||
)
|
||||
@@ -607,11 +610,25 @@ class OperatorNode:
|
||||
|
||||
if not response.tool_calls:
|
||||
no_progress_attempts += 1
|
||||
if no_progress_attempts >= no_progress_limit:
|
||||
raise OperatorNoProgressError(
|
||||
f"模型连续 {no_progress_attempts} 次未提供可执行的工具调用,"
|
||||
"已停止任务以避免空转;未执行新的设备动作。"
|
||||
if no_progress_attempts >= (
|
||||
fallback_limit if using_tool_fallback else no_progress_limit
|
||||
):
|
||||
if using_tool_fallback:
|
||||
raise OperatorNoProgressError(
|
||||
f"首选模型连续 {no_progress_limit} 次、兜底模型连续 "
|
||||
f"{no_progress_attempts} 次未提供可执行的工具调用;"
|
||||
"已停止任务以避免空转。"
|
||||
)
|
||||
active_llm = get_llm(ctx=self.ctx, name="operator", use_fallback=True)
|
||||
using_tool_fallback = True
|
||||
no_progress_attempts = 0
|
||||
logger.warning("Operator 首选模型未调用工具,当前步骤切换到兜底模型")
|
||||
current_messages.append(
|
||||
SystemMessage(
|
||||
content="本步骤已切换兜底模型。请使用工具完成操作或明确报告阻碍。"
|
||||
)
|
||||
)
|
||||
continue
|
||||
logger.warning("LLM returned no tool call; requesting a concrete tool call.")
|
||||
current_messages.append(response)
|
||||
current_messages.append(
|
||||
@@ -931,11 +948,25 @@ class OperatorNode:
|
||||
|
||||
if validation_errors:
|
||||
no_progress_attempts += 1
|
||||
if no_progress_attempts >= no_progress_limit:
|
||||
raise OperatorNoProgressError(
|
||||
f"模型连续 {no_progress_attempts} 次未提供可执行的工具调用,"
|
||||
"工具参数校验仍失败;已停止任务,未执行被拒绝的设备动作。"
|
||||
if no_progress_attempts >= (
|
||||
fallback_limit if using_tool_fallback else no_progress_limit
|
||||
):
|
||||
if using_tool_fallback:
|
||||
raise OperatorNoProgressError(
|
||||
f"首选模型连续 {no_progress_limit} 次、兜底模型连续 "
|
||||
f"{no_progress_attempts} 次未提供可执行的工具调用,"
|
||||
"工具参数校验仍失败;已停止任务。"
|
||||
)
|
||||
active_llm = get_llm(ctx=self.ctx, name="operator", use_fallback=True)
|
||||
using_tool_fallback = True
|
||||
no_progress_attempts = 0
|
||||
logger.warning("Operator 首选模型工具调用校验失败,当前步骤切换到兜底模型")
|
||||
current_messages.append(
|
||||
SystemMessage(
|
||||
content="本步骤已切换兜底模型。请修正上面的工具参数后再调用。"
|
||||
)
|
||||
)
|
||||
continue
|
||||
logger.warning("Some action calls failed validation. Feeding back to LLM.")
|
||||
continue
|
||||
no_progress_attempts = 0
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -835,6 +835,13 @@ class ProProfileConfig(BaseModel):
|
||||
model_config = {"extra": "allow"}
|
||||
|
||||
|
||||
class ScreenshotConfig(BaseModel):
|
||||
"""Limit screenshots sent to agents while retaining physical device coordinates."""
|
||||
|
||||
enabled: bool = Field(default_factory=lambda: settings.ARTEMIS_SCREENSHOT_LIMIT_ENABLED)
|
||||
max_long_edge: int = Field(default=2048, ge=256, le=8192)
|
||||
|
||||
|
||||
class AgentGlobalConfig(BaseModel):
|
||||
"""Global configuration parsed from artemis.jsonc / agent_config.json."""
|
||||
|
||||
@@ -889,6 +896,10 @@ class AgentGlobalConfig(BaseModel):
|
||||
default_factory=MemoryConfig,
|
||||
description="Unified step-memory runtime and transcript scrub options.",
|
||||
)
|
||||
screenshot: ScreenshotConfig = Field(
|
||||
default_factory=ScreenshotConfig,
|
||||
description="Agent screenshot size limit; device/UI coordinates remain native.",
|
||||
)
|
||||
|
||||
model_config = {"extra": "allow"}
|
||||
|
||||
|
||||
@@ -100,9 +100,15 @@ class Settings(BaseSettings):
|
||||
OPENAI_TIMEOUT_SECONDS: float = Field(default=300.0, gt=0)
|
||||
OPENAI_MAX_RETRIES: int = Field(default=5, ge=0, le=10)
|
||||
OPENAI_RETRY_INTERVAL_SECONDS: float = Field(default=10.0, ge=0, le=300)
|
||||
OPENAI_RATE_LIMIT_RETRY_SECONDS: float = Field(default=60.0, ge=0, le=3600)
|
||||
OPENAI_TOOL_FALLBACK_MODEL: str = Field(default="gpt-6-luna", min_length=1)
|
||||
OPENAI_TOOL_FALLBACK_REASONING_EFFORT: str = "high"
|
||||
OPENAI_TOOL_FALLBACK_MAX_RETRIES: int = Field(default=3, ge=0, le=10)
|
||||
ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS: int = Field(default=3, ge=1, le=10)
|
||||
OPENAI_REASONING_EFFORT: str = "high"
|
||||
ARTEMIS_CREDENTIAL_TIMEOUT_SECONDS: float = Field(default=12.0, gt=0)
|
||||
ARTEMIS_MODEL_CACHE_TTL_SECONDS: float = Field(default=10.0, ge=0)
|
||||
ARTEMIS_DAEMON_PROBE_TIMEOUT_SECONDS: float = Field(default=2.0, gt=0)
|
||||
ARTEMIS_VIDEO_TIMEOUT_SECONDS: float = Field(default=120.0, gt=0)
|
||||
ARTEMIS_VIDEO_MAX_RETRIES: int = Field(default=1, ge=0)
|
||||
ARTEMIS_VIDEO_CHUNK_SECONDS: float = Field(default=60.0, gt=0)
|
||||
@@ -112,10 +118,17 @@ class Settings(BaseSettings):
|
||||
ARTEMIS_RECORDING_ENABLED: bool = False
|
||||
ARTEMIS_OUTPUTTER_ENABLED: bool = True
|
||||
ARTEMIS_STEP_SUMMARIZER_ENABLED: bool = True
|
||||
ARTEMIS_SCREENSHOT_LIMIT_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)
|
||||
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)
|
||||
|
||||
@@ -19,18 +19,22 @@ 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 (
|
||||
UIAutomatorClient,
|
||||
_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
|
||||
from artemis.utils.video import build_scrcpy_record_command
|
||||
from artemis.utils.ui_filter import filter_ui_hierarchy
|
||||
from artemis.utils.logger import get_logger
|
||||
from artemis.utils.screenshot_limit import limit_screenshot_long_edge
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -88,6 +92,8 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
||||
self._device: AdbDevice | None = None
|
||||
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:
|
||||
@@ -119,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)
|
||||
@@ -128,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)
|
||||
@@ -155,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")
|
||||
@@ -167,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
|
||||
@@ -186,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}")
|
||||
@@ -205,16 +245,15 @@ 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
|
||||
)
|
||||
if limited_bytes is not clean_bytes:
|
||||
clean_bytes = limited_bytes
|
||||
clean_b64 = base64.b64encode(clean_bytes).decode("utf-8")
|
||||
|
||||
return ScreenData(
|
||||
screenshot_bytes=clean_bytes,
|
||||
@@ -245,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}")
|
||||
@@ -265,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}")
|
||||
@@ -314,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(
|
||||
@@ -336,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}")
|
||||
@@ -349,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.
|
||||
@@ -360,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}")
|
||||
@@ -376,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}")
|
||||
@@ -385,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}")
|
||||
@@ -393,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}")
|
||||
@@ -429,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:
|
||||
|
||||
@@ -117,6 +117,11 @@ def init_command() -> None:
|
||||
"OPENAI_TIMEOUT_SECONDS=300",
|
||||
"OPENAI_MAX_RETRIES=5",
|
||||
"OPENAI_RETRY_INTERVAL_SECONDS=10",
|
||||
"OPENAI_RATE_LIMIT_RETRY_SECONDS=60",
|
||||
"OPENAI_TOOL_FALLBACK_MODEL=gpt-6-luna",
|
||||
"OPENAI_TOOL_FALLBACK_REASONING_EFFORT=high",
|
||||
"OPENAI_TOOL_FALLBACK_MAX_RETRIES=3",
|
||||
"ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS=3",
|
||||
"ARTEMIS_VIDEO_ENABLED=false",
|
||||
"ARTEMIS_TRACES_DIR=./traces",
|
||||
"",
|
||||
|
||||
@@ -51,212 +51,28 @@ ARTEMIS_MCP_TOOLS = (
|
||||
)
|
||||
|
||||
|
||||
def _eager_tools_config() -> dict[str, dict[str, bool]]:
|
||||
"""Return the eager-loading extension used by clients that support it."""
|
||||
return {tool_name: {"eager": True} for tool_name in ARTEMIS_MCP_TOOLS}
|
||||
|
||||
|
||||
def _get_vscode_user_dir() -> Path:
|
||||
"""Returns the platform-specific VS Code user configuration directory."""
|
||||
if sys.platform == "darwin":
|
||||
return Path.home() / "Library" / "Application Support" / "Code" / "User"
|
||||
elif sys.platform == "win32":
|
||||
appdata = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming")
|
||||
return Path(appdata) / "Code" / "User"
|
||||
else:
|
||||
return Path.home() / ".config" / "Code" / "User"
|
||||
|
||||
|
||||
def _get_config_snippet(client: str, python_exe: str, project_root: str) -> dict:
|
||||
"""Generates MCP configuration dictionary for the specified client."""
|
||||
config_body = {
|
||||
"command": python_exe,
|
||||
"args": ["-m", "mcp_server"],
|
||||
"env": {
|
||||
"PYTHONUNBUFFERED": "1",
|
||||
"PYTHONPATH": project_root,
|
||||
"ARTEMIS_DESKTOP_NOTIFY": "true",
|
||||
},
|
||||
"""Generate the supported Codex MCP configuration."""
|
||||
if client != "codex":
|
||||
raise ValueError("Only Codex MCP configuration is supported")
|
||||
return {
|
||||
"mcp_servers": {
|
||||
"artemis": {
|
||||
"command": python_exe,
|
||||
"args": ["-m", "mcp_server"],
|
||||
"cwd": project_root,
|
||||
"env": {
|
||||
"PYTHONUNBUFFERED": "1",
|
||||
"PYTHONPATH": project_root,
|
||||
"ARTEMIS_DESKTOP_NOTIFY": "true",
|
||||
},
|
||||
"enabled": True,
|
||||
"required": True,
|
||||
"startup_timeout_sec": 120,
|
||||
"enabled_tools": list(ARTEMIS_MCP_TOOLS),
|
||||
}
|
||||
}
|
||||
}
|
||||
config_with_cwd = {**config_body, "cwd": project_root}
|
||||
|
||||
if client == "antigravity":
|
||||
return {
|
||||
"mcpServers": {
|
||||
"artemis": {
|
||||
**config_with_cwd,
|
||||
# This is the documented way to expose every tool. Avoid
|
||||
# proprietary eager fields in the current config so a
|
||||
# strict parser can always load the server.
|
||||
"disabledTools": [],
|
||||
}
|
||||
}
|
||||
}
|
||||
elif client == "jetski":
|
||||
return {
|
||||
"mcpServers": {
|
||||
"artemis": {
|
||||
**config_with_cwd,
|
||||
"disabledTools": [],
|
||||
# Retain the legacy Jetski eager extension only in its
|
||||
# legacy config file. Current Antigravity gets the strict,
|
||||
# documented configuration above as a safe fallback.
|
||||
"tools": _eager_tools_config(),
|
||||
}
|
||||
}
|
||||
}
|
||||
elif client == "openclaw":
|
||||
return {
|
||||
"mcp": {
|
||||
"servers": {
|
||||
"artemis": {
|
||||
**config_with_cwd,
|
||||
"enabled": True,
|
||||
"connectionTimeoutMs": 120000,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
elif client == "codex":
|
||||
return {
|
||||
"mcp_servers": {
|
||||
"artemis": {
|
||||
**config_with_cwd,
|
||||
# Codex has no eager/deferred MCP setting. Requiring the
|
||||
# server makes it initialize during host startup, while an
|
||||
# explicit allow-list guarantees all Artemis tools are
|
||||
# registered. The host may still present schemas through
|
||||
# tool search to control context size.
|
||||
"enabled": True,
|
||||
"required": True,
|
||||
"startup_timeout_sec": 120,
|
||||
"enabled_tools": list(ARTEMIS_MCP_TOOLS),
|
||||
}
|
||||
}
|
||||
}
|
||||
elif client == "vscode":
|
||||
return {"servers": {"artemis": {**config_with_cwd, "type": "stdio"}}}
|
||||
elif client in ("claude", "claude_code", "claude_desktop"):
|
||||
return {"mcpServers": {"artemis": {**config_body, "type": "stdio"}}}
|
||||
elif client == "cline":
|
||||
return {
|
||||
"mcpServers": {
|
||||
"artemis": {
|
||||
**config_body,
|
||||
"disabled": False,
|
||||
"autoApprove": [],
|
||||
"timeout": 120,
|
||||
}
|
||||
}
|
||||
}
|
||||
elif client in ("roo", "roo_code"):
|
||||
return {
|
||||
"mcpServers": {
|
||||
"artemis": {
|
||||
**config_body,
|
||||
"disabled": False,
|
||||
"alwaysAllow": [],
|
||||
"timeout": 120,
|
||||
}
|
||||
}
|
||||
}
|
||||
else: # cursor, windsurf, generic
|
||||
return {"mcpServers": {"artemis": config_body}}
|
||||
|
||||
|
||||
def _parse_json_lenient(text: str) -> dict | None:
|
||||
"""Parses JSON or JSONC (JSON with comments / trailing commas) safely.
|
||||
|
||||
Returns ``None`` when the text cannot be parsed as a JSON object, so callers
|
||||
can tell a genuinely empty ``{}`` file apart from a corrupt one.
|
||||
"""
|
||||
try:
|
||||
data = json.loads(text)
|
||||
return data if isinstance(data, dict) else None
|
||||
except ValueError:
|
||||
# Not strict JSON; fall through to the lenient JSONC cleanup below.
|
||||
pass
|
||||
# Remove // single-line comments
|
||||
cleaned = re.sub(r"//.*", "", text)
|
||||
# Remove /* ... */ multi-line comments
|
||||
cleaned = re.sub(r"/\*.*?\*/", "", cleaned, flags=re.DOTALL)
|
||||
# Remove trailing commas before } or ]
|
||||
cleaned = re.sub(r",\s*([}\]])", r"\1", cleaned)
|
||||
try:
|
||||
data = json.loads(cleaned)
|
||||
return data if isinstance(data, dict) else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _merge_json_file(
|
||||
file_path: Path,
|
||||
server_name: str,
|
||||
server_config: dict,
|
||||
key_name: str | tuple[str, ...] = "mcpServers",
|
||||
remove_paths: tuple[tuple[str, ...], ...] = (),
|
||||
strict_parse: bool = False,
|
||||
) -> bool:
|
||||
"""Merges server configuration into a target JSON file without overwriting existing servers.
|
||||
|
||||
With ``strict_parse=True``, an existing non-empty file that cannot be parsed
|
||||
aborts the merge instead of being replaced. Use this for files that hold
|
||||
state beyond MCP config (e.g. ~/.claude.json), where a degraded rewrite
|
||||
would destroy unrelated data.
|
||||
"""
|
||||
try:
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
data: dict = {}
|
||||
if file_path.exists() and file_path.stat().st_size > 0:
|
||||
raw_text = file_path.read_text(encoding="utf-8")
|
||||
parsed = _parse_json_lenient(raw_text)
|
||||
data = parsed or {}
|
||||
if parsed is None and raw_text.strip():
|
||||
if strict_parse:
|
||||
logger.warning(
|
||||
f"Existing JSON in {file_path} could not be parsed; refusing to rewrite it. "
|
||||
"Fix or remove the file, then re-run the install."
|
||||
)
|
||||
return False
|
||||
# Backup unparseable existing file to prevent accidental loss
|
||||
backup_path = file_path.with_suffix(file_path.suffix + ".bak")
|
||||
try:
|
||||
backup_path.write_text(raw_text, encoding="utf-8")
|
||||
logger.warning(
|
||||
f"Existing JSON in {file_path} could not be parsed; backup created at {backup_path}"
|
||||
)
|
||||
except OSError as exc:
|
||||
logger.warning(
|
||||
f"Existing JSON in {file_path} could not be parsed AND the backup to"
|
||||
f" {backup_path} failed ({exc}); refusing to rewrite it."
|
||||
" Fix or remove the file, then re-run the install."
|
||||
)
|
||||
return False
|
||||
|
||||
for remove_path in remove_paths:
|
||||
parent = data
|
||||
for part in remove_path[:-1]:
|
||||
child = parent.get(part)
|
||||
if not isinstance(child, dict):
|
||||
parent = {}
|
||||
break
|
||||
parent = child
|
||||
if parent:
|
||||
parent.pop(remove_path[-1], None)
|
||||
|
||||
key_path = (key_name,) if isinstance(key_name, str) else key_name
|
||||
target = data
|
||||
for part in key_path:
|
||||
if not isinstance(target.get(part), dict):
|
||||
target[part] = {}
|
||||
target = target[part]
|
||||
|
||||
target[server_name] = server_config
|
||||
file_path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not update MCP config file {file_path}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _codex_toml_block(server_config: dict) -> str:
|
||||
@@ -302,7 +118,9 @@ def _merge_codex_toml(file_path: Path, server_config: dict) -> bool:
|
||||
managed_pattern = re.compile(
|
||||
rf"(?ms)^{re.escape(begin_marker)}\n.*?^{re.escape(end_marker)}\n?"
|
||||
)
|
||||
updated = managed_pattern.sub(managed_block, existing, count=1)
|
||||
# Windows paths contain backslashes; a replacement string would
|
||||
# interpret them as regex escapes on a second installation.
|
||||
updated = managed_pattern.sub(lambda _: managed_block, existing, count=1)
|
||||
else:
|
||||
# Remove an older, unmanaged Artemis table before adding the managed block.
|
||||
kept_lines: list[str] = []
|
||||
@@ -342,36 +160,6 @@ def _merge_codex_toml(file_path: Path, server_config: dict) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _remove_rules_block(file_path: Path) -> bool:
|
||||
"""Removes a previously injected managed ARTEMIS block from a rules file.
|
||||
|
||||
Used to migrate clients that load both their main rules file and a
|
||||
standalone rule file (e.g. Claude Code loads ~/.claude/CLAUDE.md AND
|
||||
~/.claude/rules/*.md), where keeping the injected block would duplicate
|
||||
the rules in the model's context. If the file contains nothing but the
|
||||
managed block, the file is deleted entirely.
|
||||
"""
|
||||
try:
|
||||
if not file_path.exists():
|
||||
return True
|
||||
begin_marker = "<!-- BEGIN ARTEMIS MOBILE TESTING RULES -->"
|
||||
end_marker = "<!-- END ARTEMIS MOBILE TESTING RULES -->"
|
||||
existing = file_path.read_text(encoding="utf-8")
|
||||
if begin_marker not in existing or end_marker not in existing:
|
||||
return True
|
||||
prefix = existing.split(begin_marker)[0]
|
||||
suffix = existing.split(end_marker)[1]
|
||||
remainder = f"{prefix.rstrip()}\n\n{suffix.lstrip()}".strip()
|
||||
if remainder:
|
||||
file_path.write_text(remainder + "\n", encoding="utf-8")
|
||||
else:
|
||||
file_path.unlink()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not remove rules block from {file_path}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _inject_rules_block(file_path: Path, content: str) -> bool:
|
||||
"""Injects or updates a managed ARTEMIS block in a global rules file without overwriting user content."""
|
||||
try:
|
||||
@@ -404,297 +192,36 @@ def _inject_rules_block(file_path: Path, content: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _write_rule_file(file_path: Path, content: str) -> bool:
|
||||
"""Writes or overwrites a standalone rule file."""
|
||||
try:
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
new_text = content.strip() + "\n"
|
||||
if file_path.exists():
|
||||
try:
|
||||
if file_path.read_text(encoding="utf-8") == new_text:
|
||||
return True
|
||||
except (OSError, UnicodeDecodeError):
|
||||
# Unreadable existing file: skip the no-op check and rewrite it.
|
||||
pass
|
||||
file_path.write_text(new_text, encoding="utf-8")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not write rule file {file_path}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _write_cursor_mdc(file_path: Path, content: str) -> bool:
|
||||
"""Writes a Cursor .mdc rule file with required YAML frontmatter."""
|
||||
try:
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
mdc_header = (
|
||||
"---\n"
|
||||
"description: Artemis Mobile Testing Mindset & Rules\n"
|
||||
"globs: **/*\n"
|
||||
"alwaysApply: true\n"
|
||||
"---\n\n"
|
||||
)
|
||||
new_text = mdc_header + content.strip() + "\n"
|
||||
if file_path.exists():
|
||||
try:
|
||||
if file_path.read_text(encoding="utf-8") == new_text:
|
||||
return True
|
||||
except (OSError, UnicodeDecodeError):
|
||||
# Unreadable existing file: skip the no-op check and rewrite it.
|
||||
pass
|
||||
file_path.write_text(new_text, encoding="utf-8")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not write Cursor rule file {file_path}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def install_rules(client: str, project_root: str) -> list[str]:
|
||||
"""Auto-installs ARTEMIS testing rules globally into user IDE rule directories across any OS."""
|
||||
installed_paths: list[str] = []
|
||||
"""Install testing rules only into the Codex instructions file."""
|
||||
if client != "codex":
|
||||
raise ValueError("Only Codex MCP configuration is supported")
|
||||
rules_src = Path(project_root) / "mcp_server" / "rules.md"
|
||||
if not rules_src.exists():
|
||||
alt_src = Path(__file__).resolve().parents[4] / "mcp_server" / "rules.md"
|
||||
if alt_src.exists():
|
||||
rules_src = alt_src
|
||||
else:
|
||||
logger.warning(f"Could not locate rules.md at {rules_src}")
|
||||
return []
|
||||
|
||||
rules_src = Path(__file__).resolve().parents[4] / "mcp_server" / "rules.md"
|
||||
try:
|
||||
raw_rules = rules_src.read_text(encoding="utf-8").strip()
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not read rules file {rules_src}: {e}")
|
||||
except OSError:
|
||||
logger.warning(f"Could not read rules file {rules_src}")
|
||||
return []
|
||||
|
||||
targets = (
|
||||
[
|
||||
"antigravity",
|
||||
"cursor",
|
||||
"claude",
|
||||
"windsurf",
|
||||
"vscode",
|
||||
"cline",
|
||||
"roo",
|
||||
"openclaw",
|
||||
"codex",
|
||||
]
|
||||
if client == "all"
|
||||
else [client]
|
||||
codex_home = Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex"))
|
||||
override_file = codex_home / "AGENTS.override.md"
|
||||
agents_file = (
|
||||
override_file
|
||||
if override_file.exists() and override_file.read_text(encoding="utf-8").strip()
|
||||
else codex_home / "AGENTS.md"
|
||||
)
|
||||
|
||||
for target in targets:
|
||||
if target in ("antigravity", "jetski"):
|
||||
gemini_md = Path.home() / ".gemini" / "GEMINI.md"
|
||||
global_rule = Path.home() / ".gemini" / "rules" / "artemis.md"
|
||||
if _inject_rules_block(gemini_md, raw_rules):
|
||||
installed_paths.append(str(gemini_md))
|
||||
if _write_rule_file(global_rule, raw_rules):
|
||||
installed_paths.append(str(global_rule))
|
||||
elif target == "cursor":
|
||||
cursor_rules_file = Path.home() / ".cursorrules"
|
||||
global_mdc = Path.home() / ".cursor" / "rules" / "artemis.mdc"
|
||||
if _inject_rules_block(cursor_rules_file, raw_rules):
|
||||
installed_paths.append(str(cursor_rules_file))
|
||||
if _write_cursor_mdc(global_mdc, raw_rules):
|
||||
installed_paths.append(str(global_mdc))
|
||||
elif target in ("claude", "claude_code"):
|
||||
# Claude Code loads BOTH ~/.claude/CLAUDE.md and ~/.claude/rules/*.md
|
||||
# into context every session, so install the rules in exactly one
|
||||
# place (the standalone rule file) and migrate away any block a
|
||||
# previous version injected into CLAUDE.md.
|
||||
claude_md = Path.home() / ".claude" / "CLAUDE.md"
|
||||
global_rule = Path.home() / ".claude" / "rules" / "artemis.md"
|
||||
_remove_rules_block(claude_md)
|
||||
if _write_rule_file(global_rule, raw_rules):
|
||||
installed_paths.append(str(global_rule))
|
||||
elif target == "claude_desktop":
|
||||
# Claude Desktop only reads ~/.claude/CLAUDE.md (not rules/*.md), so
|
||||
# the rules block stays injected there for this target.
|
||||
claude_md = Path.home() / ".claude" / "CLAUDE.md"
|
||||
if _inject_rules_block(claude_md, raw_rules):
|
||||
installed_paths.append(str(claude_md))
|
||||
elif target == "windsurf":
|
||||
global_rule = Path.home() / ".codeium" / "windsurf" / "rules" / "artemis.md"
|
||||
global_mem = Path.home() / ".codeium" / "windsurf" / "memories" / "global_rules.md"
|
||||
if _inject_rules_block(global_mem, raw_rules):
|
||||
installed_paths.append(str(global_mem))
|
||||
if _write_rule_file(global_rule, raw_rules):
|
||||
installed_paths.append(str(global_rule))
|
||||
elif target == "vscode":
|
||||
global_rule = Path.home() / ".vscode" / "rules" / "artemis.md"
|
||||
user_rule = _get_vscode_user_dir() / "rules" / "artemis.md"
|
||||
if _write_rule_file(global_rule, raw_rules):
|
||||
installed_paths.append(str(global_rule))
|
||||
if _write_rule_file(user_rule, raw_rules):
|
||||
installed_paths.append(str(user_rule))
|
||||
elif target == "cline":
|
||||
clinerules = Path.home() / ".clinerules"
|
||||
global_rule = Path.home() / ".cline" / "rules" / "artemis.md"
|
||||
if _inject_rules_block(clinerules, raw_rules):
|
||||
installed_paths.append(str(clinerules))
|
||||
if _write_rule_file(global_rule, raw_rules):
|
||||
installed_paths.append(str(global_rule))
|
||||
elif target in ("roo", "roo_code"):
|
||||
roorules = Path.home() / ".roorules"
|
||||
global_rule = Path.home() / ".roo" / "rules" / "artemis.md"
|
||||
if _inject_rules_block(roorules, raw_rules):
|
||||
installed_paths.append(str(roorules))
|
||||
if _write_rule_file(global_rule, raw_rules):
|
||||
installed_paths.append(str(global_rule))
|
||||
elif target == "openclaw":
|
||||
openclaw_md = Path.home() / ".openclaw" / "OPENCLAW.md"
|
||||
global_rule = Path.home() / ".openclaw" / "rules" / "artemis.md"
|
||||
if _inject_rules_block(openclaw_md, raw_rules):
|
||||
installed_paths.append(str(openclaw_md))
|
||||
if _write_rule_file(global_rule, raw_rules):
|
||||
installed_paths.append(str(global_rule))
|
||||
elif target == "codex":
|
||||
codex_home = Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex"))
|
||||
override_file = codex_home / "AGENTS.override.md"
|
||||
agents_file = (
|
||||
override_file
|
||||
if override_file.exists() and override_file.read_text(encoding="utf-8").strip()
|
||||
else codex_home / "AGENTS.md"
|
||||
)
|
||||
if _inject_rules_block(agents_file, raw_rules):
|
||||
installed_paths.append(str(agents_file))
|
||||
|
||||
return installed_paths
|
||||
return [str(agents_file)] if _inject_rules_block(agents_file, raw_rules) else []
|
||||
|
||||
|
||||
def install_mcp_config(client: str, python_exe: str, project_root: str) -> list[str]:
|
||||
"""Auto-installs/merges ARTEMIS MCP configuration and testing rules into IDE config files across any OS."""
|
||||
installed_paths: list[str] = []
|
||||
targets = (
|
||||
[
|
||||
"antigravity",
|
||||
"cursor",
|
||||
"claude",
|
||||
"windsurf",
|
||||
"vscode",
|
||||
"cline",
|
||||
"roo",
|
||||
"openclaw",
|
||||
"codex",
|
||||
]
|
||||
if client == "all"
|
||||
else [client]
|
||||
)
|
||||
|
||||
for target in targets:
|
||||
snippet = _get_config_snippet(target, python_exe, project_root)
|
||||
if target in ("antigravity", "jetski"):
|
||||
current_server_cfg = _get_config_snippet("antigravity", python_exe, project_root)[
|
||||
"mcpServers"
|
||||
]["artemis"]
|
||||
legacy_server_cfg = _get_config_snippet("jetski", python_exe, project_root)[
|
||||
"mcpServers"
|
||||
]["artemis"]
|
||||
jetski_path = Path.home() / ".gemini" / "jetski" / "mcp_config.json"
|
||||
antigravity_legacy_path = Path.home() / ".gemini" / "antigravity" / "mcp_config.json"
|
||||
config_path = Path.home() / ".gemini" / "config" / "mcp_config.json"
|
||||
if _merge_json_file(jetski_path, "artemis", legacy_server_cfg):
|
||||
installed_paths.append(str(jetski_path))
|
||||
if _merge_json_file(antigravity_legacy_path, "artemis", current_server_cfg):
|
||||
installed_paths.append(str(antigravity_legacy_path))
|
||||
if _merge_json_file(config_path, "artemis", current_server_cfg):
|
||||
installed_paths.append(str(config_path))
|
||||
elif target in ("claude", "claude_code", "claude_desktop"):
|
||||
server_cfg = snippet["mcpServers"]["artemis"]
|
||||
if sys.platform == "darwin":
|
||||
claude_path = (
|
||||
Path.home()
|
||||
/ "Library"
|
||||
/ "Application Support"
|
||||
/ "Claude"
|
||||
/ "claude_desktop_config.json"
|
||||
)
|
||||
elif sys.platform == "win32":
|
||||
appdata = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming")
|
||||
claude_path = Path(appdata) / "Claude" / "claude_desktop_config.json"
|
||||
else:
|
||||
claude_path = Path.home() / ".config" / "Claude" / "claude_desktop_config.json"
|
||||
if _merge_json_file(claude_path, "artemis", server_cfg):
|
||||
installed_paths.append(str(claude_path))
|
||||
|
||||
# Also install to Claude Code CLI global config (~/.claude.json).
|
||||
# This file holds Claude Code state well beyond MCP config, so a
|
||||
# parse failure must abort the merge rather than rewrite the file.
|
||||
claude_code_path = Path.home() / ".claude.json"
|
||||
if _merge_json_file(claude_code_path, "artemis", server_cfg, strict_parse=True):
|
||||
installed_paths.append(str(claude_code_path))
|
||||
elif target == "cursor":
|
||||
server_cfg = snippet["mcpServers"]["artemis"]
|
||||
cursor_path = Path.home() / ".cursor" / "mcp.json"
|
||||
if _merge_json_file(cursor_path, "artemis", server_cfg):
|
||||
installed_paths.append(str(cursor_path))
|
||||
elif target == "windsurf":
|
||||
server_cfg = snippet["mcpServers"]["artemis"]
|
||||
windsurf_path = Path.home() / ".codeium" / "windsurf" / "mcp_config.json"
|
||||
if _merge_json_file(windsurf_path, "artemis", server_cfg):
|
||||
installed_paths.append(str(windsurf_path))
|
||||
elif target == "vscode":
|
||||
server_cfg = snippet["servers"]["artemis"]
|
||||
vscode_path = _get_vscode_user_dir() / "mcp.json"
|
||||
copilot_path = Path.home() / ".copilot" / "mcp-config.json"
|
||||
if _merge_json_file(vscode_path, "artemis", server_cfg, key_name="servers"):
|
||||
installed_paths.append(str(vscode_path))
|
||||
if _merge_json_file(copilot_path, "artemis", server_cfg, key_name="servers"):
|
||||
installed_paths.append(str(copilot_path))
|
||||
elif target == "cline":
|
||||
server_cfg = snippet["mcpServers"]["artemis"]
|
||||
cline_paths = (
|
||||
Path.home() / ".cline" / "data" / "settings" / "cline_mcp_settings.json",
|
||||
Path.home() / ".cline" / "mcp.json",
|
||||
_get_vscode_user_dir()
|
||||
/ "globalStorage"
|
||||
/ "saoudrizwan.claude-dev"
|
||||
/ "settings"
|
||||
/ "cline_mcp_settings.json",
|
||||
)
|
||||
for cline_path in cline_paths:
|
||||
if _merge_json_file(cline_path, "artemis", server_cfg):
|
||||
installed_paths.append(str(cline_path))
|
||||
elif target in ("roo", "roo_code"):
|
||||
server_cfg = snippet["mcpServers"]["artemis"]
|
||||
roo_settings_dir = (
|
||||
_get_vscode_user_dir() / "globalStorage" / "rooveterinaryinc.roo-cline" / "settings"
|
||||
)
|
||||
roo_paths = (
|
||||
roo_settings_dir / "mcp_settings.json",
|
||||
roo_settings_dir / "cline_mcp_settings.json",
|
||||
Path(project_root) / ".roo" / "mcp.json",
|
||||
)
|
||||
for roo_path in roo_paths:
|
||||
if _merge_json_file(roo_path, "artemis", server_cfg):
|
||||
installed_paths.append(str(roo_path))
|
||||
elif target == "openclaw":
|
||||
server_cfg = snippet["mcp"]["servers"]["artemis"]
|
||||
openclaw_path = Path.home() / ".openclaw" / "openclaw.json"
|
||||
if _merge_json_file(
|
||||
openclaw_path,
|
||||
"artemis",
|
||||
server_cfg,
|
||||
key_name=("mcp", "servers"),
|
||||
remove_paths=(("plugins", "artemis_mcp"),),
|
||||
):
|
||||
installed_paths.append(str(openclaw_path))
|
||||
elif target == "codex":
|
||||
server_cfg = snippet["mcp_servers"]["artemis"]
|
||||
codex_home = Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex"))
|
||||
codex_path = codex_home / "config.toml"
|
||||
if _merge_codex_toml(codex_path, server_cfg):
|
||||
installed_paths.append(str(codex_path))
|
||||
|
||||
rules_paths = install_rules(client, project_root)
|
||||
installed_paths.extend(rules_paths)
|
||||
|
||||
unique_paths: list[str] = []
|
||||
for p in installed_paths:
|
||||
if p not in unique_paths:
|
||||
unique_paths.append(p)
|
||||
return unique_paths
|
||||
"""Install ARTEMIS configuration and rules only for Codex."""
|
||||
server_cfg = _get_config_snippet(client, python_exe, project_root)["mcp_servers"]["artemis"]
|
||||
codex_home = Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex"))
|
||||
codex_path = codex_home / "config.toml"
|
||||
if not _merge_codex_toml(codex_path, server_cfg):
|
||||
raise typer.Exit(1)
|
||||
return [str(codex_path), *install_rules(client, project_root)]
|
||||
|
||||
|
||||
def mcp_command(
|
||||
@@ -733,7 +260,7 @@ def mcp_command(
|
||||
typer.Option(
|
||||
"--install",
|
||||
"-i",
|
||||
help="Auto-install and merge ARTEMIS MCP configuration and testing rules into 'antigravity', 'claude', 'cursor', 'windsurf', 'vscode', 'cline', 'roo', 'openclaw', 'codex', or 'all'.",
|
||||
help="Install ARTEMIS MCP configuration and testing rules for Codex: --install codex.",
|
||||
),
|
||||
] = None,
|
||||
generate_config: Annotated[
|
||||
@@ -741,7 +268,7 @@ def mcp_command(
|
||||
typer.Option(
|
||||
"--generate-config",
|
||||
"-g",
|
||||
help="Output ready-to-use MCP configuration for 'antigravity', 'cursor', 'claude', 'windsurf', 'vscode', 'cline', 'roo', 'openclaw', 'codex', or 'all'.",
|
||||
help="Output ready-to-use Codex TOML configuration: --generate-config codex.",
|
||||
),
|
||||
] = None,
|
||||
) -> None:
|
||||
@@ -751,24 +278,9 @@ def mcp_command(
|
||||
|
||||
if install_config:
|
||||
client = install_config.lower()
|
||||
if client not in (
|
||||
"antigravity",
|
||||
"jetski",
|
||||
"claude",
|
||||
"claude_code",
|
||||
"claude_desktop",
|
||||
"cursor",
|
||||
"windsurf",
|
||||
"vscode",
|
||||
"cline",
|
||||
"roo",
|
||||
"roo_code",
|
||||
"openclaw",
|
||||
"codex",
|
||||
"all",
|
||||
):
|
||||
if client != "codex":
|
||||
console.print(
|
||||
f"[bold red]Unsupported install target: '{install_config}'. Use 'antigravity', 'claude', 'cursor', 'windsurf', 'vscode', 'cline', 'roo', 'openclaw', 'codex', or 'all'.[/bold red]"
|
||||
"[bold red]目前仅支持 Codex MCP 配置,请使用 --install codex。[/bold red]"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
installed_paths = install_mcp_config(client, python_exe, project_root)
|
||||
@@ -784,46 +296,14 @@ def mcp_command(
|
||||
|
||||
if generate_config:
|
||||
client = generate_config.lower()
|
||||
if client == "all":
|
||||
all_configs = {
|
||||
"antigravity (~/.gemini/jetski/mcp_config.json)": _get_config_snippet(
|
||||
"antigravity", python_exe, project_root
|
||||
),
|
||||
"cursor (.cursor/mcp.json)": _get_config_snippet(
|
||||
"cursor", python_exe, project_root
|
||||
),
|
||||
"claude (claude_desktop_config.json & ~/.claude.json)": _get_config_snippet(
|
||||
"claude", python_exe, project_root
|
||||
),
|
||||
"windsurf (~/.codeium/windsurf/mcp_config.json)": _get_config_snippet(
|
||||
"windsurf", python_exe, project_root
|
||||
),
|
||||
"vscode (mcp.json)": _get_config_snippet("vscode", python_exe, project_root),
|
||||
"cline (cline_mcp_settings.json)": _get_config_snippet(
|
||||
"cline", python_exe, project_root
|
||||
),
|
||||
"roo (cline_mcp_settings.json)": _get_config_snippet(
|
||||
"roo", python_exe, project_root
|
||||
),
|
||||
"openclaw (openclaw.json)": _get_config_snippet(
|
||||
"openclaw", python_exe, project_root
|
||||
),
|
||||
"codex (~/.codex/config.toml)": _get_config_snippet(
|
||||
"codex", python_exe, project_root
|
||||
),
|
||||
}
|
||||
json_str = json.dumps(all_configs, indent=2)
|
||||
syntax_language = "json"
|
||||
elif client == "codex":
|
||||
server_cfg = _get_config_snippet("codex", python_exe, project_root)["mcp_servers"][
|
||||
"artemis"
|
||||
]
|
||||
json_str = _codex_toml_block(server_cfg)
|
||||
syntax_language = "toml"
|
||||
else:
|
||||
snippet = _get_config_snippet(client, python_exe, project_root)
|
||||
json_str = json.dumps(snippet, indent=2)
|
||||
syntax_language = "json"
|
||||
if client != "codex":
|
||||
console.print(
|
||||
"[bold red]目前仅支持 Codex MCP 配置,请使用 --generate-config codex。[/bold red]"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
server_cfg = _get_config_snippet(client, python_exe, project_root)["mcp_servers"]["artemis"]
|
||||
json_str = _codex_toml_block(server_cfg)
|
||||
syntax_language = "toml"
|
||||
|
||||
syntax = Syntax(json_str, syntax_language, theme="monokai", line_numbers=False)
|
||||
console.print(f"[bold cyan]MCP Configuration for {client.upper()}:[/bold cyan]")
|
||||
|
||||
@@ -75,7 +75,7 @@ def extract_status_code(error: BaseException) -> int | None:
|
||||
return None
|
||||
|
||||
|
||||
_RATE_LIMIT_MARKERS = ("rate limit", "quota", "resource_exhausted")
|
||||
_RATE_LIMIT_MARKERS = ("rate limit", "too many requests", "quota", "resource_exhausted")
|
||||
_UNAVAILABLE_MARKERS = ("unavailable", "overloaded", "high demand", "service unavailable")
|
||||
_AUTH_MARKERS = ("unauthorized", "forbidden", "api key", "credential")
|
||||
# "deadline exceeded"/"deadline_exceeded" cover google-genai SDK timeouts that
|
||||
@@ -196,6 +196,10 @@ def retry_policy_for(category: FailureCategory | str) -> RetryPolicy:
|
||||
return _DEFAULT_RETRY_POLICIES[category]
|
||||
from artemis.config.settings import settings
|
||||
|
||||
if category is FailureCategory.RATE_LIMIT:
|
||||
return _configured_policy(
|
||||
settings.OPENAI_MAX_RETRIES, settings.OPENAI_RATE_LIMIT_RETRY_SECONDS
|
||||
)
|
||||
return _configured_policy(settings.OPENAI_MAX_RETRIES, settings.OPENAI_RETRY_INTERVAL_SECONDS)
|
||||
|
||||
|
||||
|
||||
+24
-13
@@ -15,25 +15,36 @@
|
||||
"""Backward compatibility entrypoint for artemis CLI."""
|
||||
|
||||
import sys
|
||||
|
||||
import typer
|
||||
|
||||
from artemis.interfaces.cli.main import app
|
||||
from artemis.interfaces.cli.main import app as _typer_app
|
||||
|
||||
|
||||
def _root_tokens() -> frozenset[str]:
|
||||
"""Read command names and root flags from the registered CLI."""
|
||||
group = typer.main.get_command(_typer_app)
|
||||
tokens: set[str] = {"-h"}
|
||||
tokens.update(getattr(group, "commands", {}) or {})
|
||||
tokens.update(group.get_help_option_names(group.context_class(group)))
|
||||
for param in group.params:
|
||||
tokens.update(getattr(param, "opts", ()))
|
||||
tokens.update(getattr(param, "secondary_opts", ()))
|
||||
return frozenset(tokens)
|
||||
|
||||
|
||||
def normalize_argv(argv: list[str]) -> list[str]:
|
||||
"""Keep implicit ``run`` for a goal while preserving registered commands."""
|
||||
if len(argv) > 1 and argv[1] not in _root_tokens():
|
||||
return [argv[0], "run", *argv[1:]]
|
||||
return list(argv)
|
||||
|
||||
|
||||
def cli():
|
||||
# If invoked directly as python -m artemis.main without subcommand 'run',
|
||||
# check if first argument is a goal rather than a subcommand
|
||||
if len(sys.argv) > 1 and sys.argv[1] not in (
|
||||
"run",
|
||||
"init",
|
||||
"doctor",
|
||||
"batch",
|
||||
"bench",
|
||||
"mcp",
|
||||
"server",
|
||||
"trace",
|
||||
"--help",
|
||||
"-h",
|
||||
):
|
||||
sys.argv.insert(1, "run")
|
||||
sys.argv[:] = normalize_argv(sys.argv)
|
||||
app()
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -13,7 +13,14 @@
|
||||
"presets": {},
|
||||
"nodes": {
|
||||
"planner": {},
|
||||
"operator": {},
|
||||
"operator": {
|
||||
"fallback": {
|
||||
"provider": "openai",
|
||||
"model": "${env:OPENAI_TOOL_FALLBACK_MODEL:-gpt-6-luna}",
|
||||
"reasoning_effort": "${env:OPENAI_TOOL_FALLBACK_REASONING_EFFORT:-high}",
|
||||
"max_retries": "${env:OPENAI_TOOL_FALLBACK_MAX_RETRIES:-3}"
|
||||
}
|
||||
},
|
||||
"video_analyzer": {},
|
||||
"object_detector": {},
|
||||
"explorer": {},
|
||||
@@ -27,6 +34,10 @@
|
||||
"checker": {}
|
||||
},
|
||||
"agent": {
|
||||
"screenshot": {
|
||||
"enabled": "${env:ARTEMIS_SCREENSHOT_LIMIT_ENABLED:-true}",
|
||||
"max_long_edge": 2048
|
||||
},
|
||||
"flash": {
|
||||
"max_turns": 0,
|
||||
"explorer_mode": "flash",
|
||||
|
||||
@@ -28,6 +28,9 @@ import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from artemis.config.paths import ROOT_DIR
|
||||
from artemis.config.settings import settings
|
||||
from artemis.runtime.process_probe import pid_is_alive
|
||||
from artemis.runtime.server_lifecycle import is_port_in_use, read_server_info
|
||||
from artemis.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -44,10 +47,11 @@ def is_standalone_forced() -> bool:
|
||||
def is_daemon_running(
|
||||
host: str = DEFAULT_DAEMON_HOST,
|
||||
port: int = DEFAULT_DAEMON_PORT,
|
||||
timeout: float = 0.3,
|
||||
timeout: float | None = None,
|
||||
) -> bool:
|
||||
"""Fast probe to check if the Artemis Daemon HTTP server is responding."""
|
||||
url = f"http://{host}:{port}/api/system/readiness"
|
||||
timeout = timeout if timeout is not None else settings.ARTEMIS_DAEMON_PROBE_TIMEOUT_SECONDS
|
||||
url = f"http://{host}:{port}/api/system/server-status"
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "Artemis-Daemon-Probe"})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as response:
|
||||
@@ -66,7 +70,7 @@ def is_daemon_running(
|
||||
def is_artemis_daemon(
|
||||
host: str = DEFAULT_DAEMON_HOST,
|
||||
port: int = DEFAULT_DAEMON_PORT,
|
||||
timeout: float = 0.5,
|
||||
timeout: float = 2.0,
|
||||
) -> bool:
|
||||
"""Identity probe: is the process on ``host:port`` the Artemis daemon?
|
||||
|
||||
@@ -190,6 +194,15 @@ def ensure_daemon_running(
|
||||
if is_daemon_running(host, port):
|
||||
return True, f"http://{host}:{port}"
|
||||
|
||||
if is_port_in_use(port, host):
|
||||
# A slow/busy HTTP loop is not a stopped daemon. Only trust persisted
|
||||
# metadata belonging to a live process on the requested endpoint.
|
||||
info = read_server_info() or {}
|
||||
if info.get("port") == port and info.get("host") == host and pid_is_alive(info.get("pid")):
|
||||
return True, f"http://{host}:{port}"
|
||||
logger.warning(f"Port {host}:{port} is occupied; refusing to spawn another daemon")
|
||||
return False, None
|
||||
|
||||
# Auto-spawn Daemon in background
|
||||
spawn_daemon(host, port)
|
||||
|
||||
@@ -221,6 +234,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 +260,7 @@ def submit_task_to_daemon(
|
||||
"session_id": session_id,
|
||||
"ingress": ingress,
|
||||
"conversation_id": conversation_id,
|
||||
"runtime_overrides": runtime_overrides,
|
||||
}
|
||||
|
||||
try:
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Prepare Android screenshots for model input without changing device coordinates."""
|
||||
|
||||
from io import BytesIO
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def limit_screenshot_long_edge(image_bytes: bytes, max_long_edge: int) -> bytes:
|
||||
"""Downscale oversized screenshots; keep smaller images byte-for-byte unchanged."""
|
||||
try:
|
||||
with Image.open(BytesIO(image_bytes)) as image:
|
||||
long_edge = max(image.size)
|
||||
if long_edge <= max_long_edge:
|
||||
return image_bytes
|
||||
scale = max_long_edge / long_edge
|
||||
size = tuple(max(1, round(side * scale)) for side in image.size)
|
||||
resized = image.convert("RGB").resize(size, Image.Resampling.LANCZOS)
|
||||
output = BytesIO()
|
||||
resized.save(output, format="JPEG", quality=80, optimize=True)
|
||||
return output.getvalue()
|
||||
except OSError:
|
||||
# Preserve the capture path's previous behavior for invalid placeholder images.
|
||||
return image_bytes
|
||||
@@ -615,9 +615,9 @@ async def render_timeline_clip(
|
||||
)
|
||||
labels.append(f"[{label}]")
|
||||
if len(labels) == 1:
|
||||
filter_parts.append(f"{labels[0]}null[outv]")
|
||||
filter_parts.append(f"{labels[0]}fps={fps}[outv]")
|
||||
else:
|
||||
filter_parts.append(f"{''.join(labels)}concat=n={len(labels)}:v=1:a=0[outv]")
|
||||
filter_parts.append(f"{''.join(labels)}concat=n={len(labels)}:v=1:a=0,fps={fps}[outv]")
|
||||
|
||||
command.extend(
|
||||
[
|
||||
|
||||
+12
-1
@@ -13,7 +13,14 @@
|
||||
"presets": {},
|
||||
"nodes": {
|
||||
"planner": {},
|
||||
"operator": {},
|
||||
"operator": {
|
||||
"fallback": {
|
||||
"provider": "openai",
|
||||
"model": "${env:OPENAI_TOOL_FALLBACK_MODEL:-gpt-6-luna}",
|
||||
"reasoning_effort": "${env:OPENAI_TOOL_FALLBACK_REASONING_EFFORT:-high}",
|
||||
"max_retries": "${env:OPENAI_TOOL_FALLBACK_MAX_RETRIES:-3}"
|
||||
}
|
||||
},
|
||||
"video_analyzer": {},
|
||||
"object_detector": {},
|
||||
"explorer": {},
|
||||
@@ -27,6 +34,10 @@
|
||||
"checker": {}
|
||||
},
|
||||
"agent": {
|
||||
"screenshot": {
|
||||
"enabled": "${env:ARTEMIS_SCREENSHOT_LIMIT_ENABLED:-true}",
|
||||
"max_long_edge": 2048
|
||||
},
|
||||
"flash": {
|
||||
"max_turns": 0,
|
||||
"explorer_mode": "flash",
|
||||
|
||||
+11
-3
@@ -2,7 +2,7 @@
|
||||
|
||||
源码内的 `skills/artemis-automation/` 是可分发 Skill,不依赖本机 Codex 的私有能力。将整个目录复制到宿主支持的 Skill 目录即可;Codex 为 `$CODEX_HOME/skills/`(未配置时 `~/.codex/skills/`)。仓库包含 Skill,启动程序不会修改用户的全局 Skill 目录。
|
||||
|
||||
Windows 一键脚本会为检测到的客户端自动安装或更新 MCP 配置。未检测到客户端时,在项目目录执行 `uv run artemis mcp --install codex`;其他客户端将 `codex` 换成对应名称,`uv run artemis mcp --help` 可查看支持列表。更新后重新加载宿主 MCP;复制 Skill 后按宿主方式重新加载技能。
|
||||
目前仅支持 Codex MCP 配置。Windows 一键脚本检测并同步 Codex;未检测到配置目录时,在项目目录执行 `uv run artemis mcp --install codex`。配置模板使用 `uv run artemis mcp --generate-config codex` 查看;其他名称和 `all` 不再支持。更新后重新加载 Codex MCP;复制 Skill 后重新加载技能。
|
||||
|
||||
可以向 AI 这样下达任务:
|
||||
|
||||
@@ -36,14 +36,22 @@ Windows 已安装用户再次运行 `start.bat` / `start-cn.bat` 时会检查当
|
||||
|
||||
Python 每次按 `uv.lock` 检查并同步缺失/变化的依赖;前端依据源码、资源及包锁文件指纹决定是否重新执行 `npm ci` 和构建,成功后才记录指纹。国内镜像选项继续生效。检测到正在执行/排队的任务时,自动更新启动会停止,让原任务继续。
|
||||
|
||||
MCP 同步会识别已安装客户端的配置目录,为缺少 ARTEMIS 条目的客户端安装当前源码的标准配置;已有标准配置即使指向旧解压目录,也会更新解释器、项目目录、环境路径和工具清单。保留用户额外环境变量、禁用状态及其他服务;自定义启动包装器保持原样并提示。无法安全解析配置时停止启动,原文件不被覆盖;可使用 `-SkipMcpSync` 仅启动网页。首次修改保留备份。源码和配置同步后需在 AI 客户端重新加载 MCP 连接或重启客户端,脚本无法替宿主重启已加载的 MCP 进程。
|
||||
MCP 同步仅检查 `$CODEX_HOME/config.toml`;未设置或为空时使用当前用户的 `~/.codex/config.toml`。日志会显示实际路径,以及新装、更新、已是最新、未检测到或自定义跳过的结果。检测到目录但缺少 ARTEMIS 条目时安装当前源码的标准配置;已有标准配置即使指向旧解压目录,也会更新解释器、项目目录、环境路径和工具清单。保留用户额外环境变量、禁用状态及其他服务;自定义启动包装器保持原样并提示。其他客户端的配置和规则文件不读取、不修改,其格式错误不会阻止启动。Codex 配置无法安全解析时停止启动,原文件不被覆盖;可使用 `-SkipMcpSync` 仅启动网页。首次修改保留备份。同步后需在 Codex 中重新加载 MCP 或重启 Codex,脚本无法替宿主重启已加载的 MCP 进程。
|
||||
|
||||
创建批次时必须提供唯一 `request_id`;请求超时后可以按编号查询或用相同内容重发,避免重复用例。创建只保存草稿,`start` 才执行。AI 无需先制造 Excel,也可直接提交 JSON;字段与示例见 [Skill 批量任务参考](../skills/artemis-automation/references/batches.md)。
|
||||
|
||||
报告按任务或批次归档到 `artifacts/mcp_exports/`,包含本地路径及服务下载链接;删除批次时会清理对应导出副本。运行中导出是快照;执行完成不等于验证通过,报告会保留未验证、失败、跳过与中断状态。
|
||||
|
||||
模型默认使用 `mimo-v2.6-flash`,也预设 `gpt-5.6-luna` 并支持自定义。思考强度最高支持 `max`,默认 `high`,选“由模型默认”不发送该参数;接口不支持时明确报错。单次请求 300 秒,首次失败后最多再试 5 次,每次等待 10 秒,SDK 不叠加重试。耗尽后沿用任务暂停/恢复机制,鉴权或非法参数立即失败。视频分析与录屏是独立开关,默认均关闭。每项批量用例可覆盖模式、模型、设备、录屏和超时等配置,不影响其他任务。暂停先返回 `pausing`,确认状态为 `task_paused` 才表示设备已释放;Pro 等待模型回复或只读诊断时可及时中断,恢复后重新观察设备。AI 可用 `mobile_manage_task(action="inject_instruction")` 提交指导并恢复;运行中的模型思考会中断并重新观察设备,已经开始的设备动作会安全结束。用 `guidance_history` 核对指导是否被领取、在任务流程中核对实际执行情况,或用 `resume` 单独恢复。
|
||||
模型默认使用 `mimo-v2.6-flash`,预设 `gpt-5.6-luna`、`gpt-6-luna`、`mimo-v2.6-pro`,并支持自定义。思考强度最高支持 `max`,默认 `high`,选“由模型默认”不发送该参数;接口不支持时明确报错。单次请求 300 秒,首次失败后最多再试 5 次,普通错误每次等待 10 秒,429 每次等待 60 秒,SDK 不叠加重试。首选模型连续 3 次没有有效工具调用时,当前步骤改用 `gpt-6-luna`(high),最多再试 3 次;下一步重新使用首选模型。兜底模型及次数可通过 `.env.example` 中的 `OPENAI_TOOL_FALLBACK_*` 和 `ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS` 配置。请求耗尽后沿用任务暂停/恢复机制,鉴权或非法参数立即失败。视频分析与录屏是独立开关,默认均关闭。每项批量用例可覆盖模式、模型、设备、录屏和超时等配置,不影响其他任务。暂停先返回 `pausing`,确认状态为 `task_paused` 才表示设备已释放;Pro 等待模型回复或只读诊断时可及时中断,恢复后重新观察设备。AI 可用 `mobile_manage_task(action="inject_instruction")` 提交指导并恢复;运行中的模型思考会中断并重新观察设备,已经开始的设备动作会安全结束。用 `guidance_history` 核对指导是否被领取、在任务流程中核对实际执行情况,或用 `resume` 单独恢复。
|
||||
|
||||
429 等待、兜底模型及次数也可在网页“模型配置 → 稳定性与兜底模型”或通过已授权的 `mobile_config(section="model")` 配置。批次 ZIP 还收录每项任务已保存的步骤截图和录屏。
|
||||
|
||||
批次任务在队列中使用“用例编号 + 用例名称”显示,提示词仍是模型实际收到的内容。任务历史展示失败原因、完成时间和总耗时;导出全部资料时以用例编号命名目录。删除非活跃批次会同步清理其任务记录、步骤、截图、录屏及生成的报告,删除后不能通过旧 `request_id` 重复执行。
|
||||
|
||||
保存全局配置要求用户明确指令;只读配置不包含密钥,MCP 不接收密钥。密钥仍在网页或 `.env` 中填写。设置对新任务生效,模型请求重试不会触发测试用例自动重跑。历史步骤证据保留,设备动作回放已移除。
|
||||
|
||||
执行保护默认总时限 1800 秒,单任务、批次和 MCP 都适用,排队及用户暂停不计时。设备观测默认 60 秒、最多 3 次,ADB 命令默认 10 秒;观测失败不会继续向模型发送占位图或旧元素索引。Flash 连续 3 次动作失败先重新观察再切换兜底,兜底仍失败则结束任务并保存原因。不能以提高步数或反复重提任务代替设备诊断。
|
||||
|
||||
`mobile_run_task(runtime_overrides={"timeout_seconds":1800,"observation_timeout_seconds":60,"observation_max_attempts":3,"device_command_timeout_seconds":10,"action_failure_attempts":3})` 可为单任务覆盖执行保护;其中 `model` 是 API 模型名,外层 `model` 仍是 Flash/Pro 模式。相同字段也支持批次的 `task_config`。全局默认通过网页功能配置或明确授权的 `mobile_config(section="capabilities", changes={"task_timeout_seconds":1800}, persist_defaults=true)` 保存,对后续任务生效,不接受密钥。
|
||||
|
||||
任务已经产生最终结果,但 SDK 线程或资源清理仍卡住时,默认最多再等待 30 秒后回收工作进程,避免继续占用队列。全局可用 `ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS` 或功能配置的 `worker_cleanup_timeout_seconds` 调整,任务 JSON 同名字段可覆盖。原有结果与截图保留,未完成保存的录屏尽力恢复。
|
||||
@@ -20,11 +20,11 @@
|
||||
4. 上传文件,查看逐行校验结果。所有错误修正后才能提交。
|
||||
5. 明确选择设备、执行模式和超时时间,然后点击“确认导入并开始执行”。
|
||||
|
||||
同一批次按顺序派发。默认失败后继续且不自动重试,也可以选择失败即停止。设备不可用、模型暂停或任务超时会暂停后续派发。暂停批次只停止后续派发。单个任务可在安全边界暂停并释放设备;批次下一项可继续,暂停的任务待提交指导或点击继续后在设备队列末尾恢复。运行中提交指导会中断当前模型思考或只读诊断,重新观察画面;已经开始的设备动作会安全结束。网页流程会显示指导的提交与领取状态,仍应检查后续动作是否符合指导。暂停等待和重新排队时间不计入该任务执行时限。
|
||||
同一批次按顺序派发。默认失败后继续且不自动重试,也可以选择失败即停止。当前任务因设备异常或超时失败后,默认继续后续用例;设置失败即停止时才停止后续派发。暂停批次只停止后续派发。单个任务可在安全边界暂停并释放设备;批次下一项可继续,暂停的任务待提交指导或点击继续后在设备队列末尾恢复。运行中提交指导会中断当前模型思考或只读诊断,重新观察画面;已经开始的设备动作会安全结束。网页流程会显示指导的提交与领取状态,仍应检查后续动作是否符合指导。暂停等待和重新排队时间不计入该任务执行时限。
|
||||
|
||||
服务重启后保留批次,等待用户继续未开始的用例。正在执行或暂停的任务标记中断,不自动重做;“复制失败项为新批次”保留原记录并产生新任务编号。
|
||||
服务启动前先取得监听端口,重复启动不会修改现有任务或服务信息。恢复时核对原任务进程和设备锁:仍存活的任务继续等待其真实结果,退出后补齐报告;已经退出且没有终态的任务标记中断,不自动重做,批次等待用户继续未开始的用例。“复制失败项为新批次”保留原记录并产生新任务编号。任务正常完成后,已失效的“服务中断”提示归档为恢复事件,不再显示为当前失败原因;启动时也会纠正已有完成记录中的此类旧提示。
|
||||
|
||||
每个任务结束后生成所选格式报告。批次结束后生成汇总及每项任务报告的 ZIP;失败、跳过和中断任务也会列入。暂停时下载的是当前快照。录屏与 AI 视频分析分别配置,录屏默认关闭;仅开启分析时,录制内容用于本次分析,结束后不保留下载文件。暂停期间不录制其他任务的设备画面。
|
||||
每个任务结束后生成所选格式报告。批次 ZIP 包含汇总、每项任务的报告和已保存的有序步骤截图;已开启录屏且文件存在时,还会按任务收录录屏片段。失败、跳过和中断任务也会列入。暂停时下载的是当前快照。录屏与 AI 视频分析分别配置,录屏默认关闭;仅开启分析时,录制内容用于本次分析,结束后不保留下载文件。暂停期间不录制其他任务的设备画面。
|
||||
|
||||
队列中批量任务按“用例编号 + 测试用例”显示,悬停可看完整提示词;失败原因、完成时间及总耗时在历史中可查看。单任务“下载全部资料”以用例编号命名压缩包内目录。删除已结束的批次会同步删除关联任务记录、步骤、截图、录屏、报告和 MCP 导出副本;正在执行或排队的批次不能删除。
|
||||
|
||||
@@ -34,6 +34,12 @@
|
||||
|
||||
## 环境配置
|
||||
|
||||
`.env` 可配置 `ARTEMIS_BATCH_TASK_TIMEOUT`(默认 1800 秒)、`ARTEMIS_IMPORT_MAX_ROWS`(默认 1000 行)和 `ARTEMIS_IMPORT_MAX_BYTES`(默认 10 MiB)。修改后重启服务。网页的单批次超时设置优先于环境默认值。
|
||||
`.env` 可配置 `ARTEMIS_TASK_TIMEOUT_SECONDS`(单任务总执行时限,默认 1800 秒)、`ARTEMIS_IMPORT_MAX_ROWS`(默认 1000 行)和 `ARTEMIS_IMPORT_MAX_BYTES`(默认 10 MiB)。修改后重启服务。网页的单批次超时设置优先于环境默认值,每行 JSON 的 `timeout_seconds` 优先于批次。单任务、批次和 MCP 均受总执行时限保护,不计排队和用户暂停时间;即使执行进程卡住,也由父进程终止并保存失败原因。
|
||||
|
||||
守护服务通过轻量状态接口探测,`ARTEMIS_DAEMON_PROBE_TIMEOUT_SECONDS` 默认 2 秒。短暂超时且端口仍被存活的原服务占用时复用原服务,不重复启动;未知进程占用端口时明确拒绝覆盖。
|
||||
|
||||
网页“功能配置 → 执行保护”及 MCP `mobile_config(section="capabilities")` 可配置总时限、设备观测时限(默认 60 秒)、设备观测次数(默认 3 次)、ADB 命令超时(默认 10 秒)、连续动作失败次数(默认 3 次)。任务 JSON 可用 `observation_timeout_seconds`、`observation_max_attempts`、`device_command_timeout_seconds`、`action_failure_attempts` 覆盖。截图失效或收到占位图时先有限重取,耗尽后结束任务,不把旧画面发给模型;有效截图即使没有 XML 也可按坐标定位。连续动作失败时先刷新画面再尝试兜底,兜底耗尽后结束,不循环重提用例。
|
||||
|
||||
批次、任务元数据和报告偏好保存在现有 SQLite 数据库的 `automation_records`、`automation_jobs` 表。全局“清空任务历史”会清除批次与执行元数据;它与上文的单批次删除不同,已导出的文件会保留。
|
||||
|
||||
任务已经产生最终结果,但 SDK 线程或资源清理仍卡住时,默认最多再等待 30 秒后回收工作进程,避免继续占用队列。全局可用 `ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS` 或功能配置的 `worker_cleanup_timeout_seconds` 调整,任务 JSON 同名字段可覆盖。原有结果与截图保留,未完成保存的录屏尽力恢复。
|
||||
@@ -0,0 +1,15 @@
|
||||
# ARTEMIS-Eapil 1.3.0
|
||||
|
||||
## 更新
|
||||
|
||||
- 截图默认限制长边为 2048 像素,可在网页或任务 JSON 中关闭限制,改用设备原始分辨率。
|
||||
- 首选模型连续 3 次未完成工具调用时,当前步骤可交给默认的 `gpt-6-luna`(high)备用模型继续;下个步骤恢复首选模型。HTTP 429 默认等待 60 秒再重试。
|
||||
- 批次资料包包含各任务的报告、步骤截图和已有录屏。下载前显示打包进度、耗时和预计剩余时间;已完成的资料包直接复用。
|
||||
- 录像时间轴显示可点击的关键步骤,便于直接跳转复盘。
|
||||
- 优化服务状态探测、CLI 命令识别及视频拼接后的帧率处理。
|
||||
|
||||
## 安装
|
||||
|
||||
下载附件 `artemis-eapil-1.3.0-source.zip`,解压后运行 `start.bat`;国内网络使用 `start-cn.bat`。附件含可继续更新的 Git 仓库。已有安装可通过启动脚本更新;升级后重启服务、刷新网页,并重新加载 MCP。
|
||||
|
||||
验证:Python 默认测试、Ruff、质量基线、核心类型检查、前端测试及构建通过。
|
||||
@@ -0,0 +1,21 @@
|
||||
# ARTEMIS-Eapil 1.3.1
|
||||
|
||||
## 修复
|
||||
|
||||
- 限制任务执行、设备观察、连续动作失败和资源清理的等待与重试。超时或失败后释放执行资源,让队列继续处理后续任务,避免单个任务长时间反复重试。
|
||||
- 修复点击录像时间轴关键步骤后,视频暂停但播放按钮状态未同步的问题。播放中跳转后继续播放,暂停时跳转保持暂停,恢复播放只需点击一次。
|
||||
- 修复服务探测超时后重复启动后台服务,导致仍在执行的任务被误标为“服务中断”的问题。启动时先占用监听端口,再初始化服务和恢复任务;已有服务占用端口时安全退出。
|
||||
- 恢复任务时保留仍存活的执行进程,并跟踪其最终结果。完成任务会清除已失效的“服务中断”提示并保留恢复记录,真实执行失败和超时原因继续显示。
|
||||
|
||||
## 安装与升级
|
||||
|
||||
下载附件 `artemis-eapil-1.3.1-source.zip`,解压后运行 `start.bat`;国内网络使用 `start-cn.bat`。源码包包含可继续更新的 Git 仓库,不包含虚拟环境、构建产物、密钥和执行记录。
|
||||
|
||||
已有安装可通过启动脚本更新。升级后重启服务、刷新网页,并重新加载 MCP,让各入口使用新版实现。
|
||||
|
||||
## 验证
|
||||
|
||||
- Python 默认测试:2246 项通过,6 项跳过,8 项未选中;最终恢复逻辑回归测试:65 项通过。
|
||||
- 前端测试:169 项通过;前端构建、Ruff、质量基线、核心类型检查及 Git 差异检查通过。
|
||||
- 使用独立进程验证重复启动不会修改现有任务或服务元数据;在浏览器验证录像关键步骤跳转及播放状态。
|
||||
- 本次验证未运行真实 Android 任务;跨录像片段跳转通过单元测试验证。
|
||||
@@ -0,0 +1,19 @@
|
||||
# ARTEMIS-Eapil 1.3.2
|
||||
|
||||
## 更新与修复
|
||||
|
||||
- MCP 配置安装与更新仅支持 Codex。一键启动不再检查或修改 VS Code、Cursor 等其他客户端的配置,其配置错误不会阻止网页启动。手动 CLI 仅接受 `--install codex` 和 `--generate-config codex`,不再支持其他客户端或 `all`。
|
||||
- 启动日志明确显示实际检查的 Codex 配置路径,以及安装、更新、已是最新、未检测到或自定义跳过的结果。未设置或空值的 `CODEX_HOME` 使用当前用户的 `.codex` 目录;保留已有自定义路径设置。
|
||||
- 修复 Windows 手动重复安装 Codex MCP 时,路径反斜杠被误当成正则替换符的问题。配置写入失败会返回失败状态。
|
||||
- 固定批处理脚本为 CRLF 换行,修复中文启动脚本在部分源码包中将中文截断并当成命令执行的问题。
|
||||
- 同步 README、AI 接入说明及随源码分发的 Skill。
|
||||
|
||||
## 安装与升级
|
||||
|
||||
下载附件 `artemis-eapil-1.3.2-source.zip`,解压后运行 `start.bat`;国内网络使用 `start-cn.bat`。附件包含可继续更新的 Git 仓库,不包含虚拟环境、构建产物、密钥和任务记录。请使用发布附件,Gitea 自动生成的“源代码”压缩包不含 Git 元数据。
|
||||
|
||||
已有安装可通过启动脚本更新。更新后重启服务、刷新网页,并重新加载 Codex MCP。未检测到 Codex 配置目录时,可运行 `uv run artemis mcp --install codex` 手动安装。仅需网页时使用 `-SkipMcpSync`。
|
||||
|
||||
## 验证
|
||||
|
||||
默认 Python 测试范围分批验证共 2267 项通过,6 项跳过,8 项按标记排除;Ruff、质量基线、核心类型检查和 Git 差异检查通过。已验证 Windows 中文启动与帮助输出、Codex 默认及自定义目录识别、重复安装、其他客户端配置保留,以及源码包 CRLF 和干净 Git 状态。本次没有修改前端交互,也未运行真实 Android 任务。
|
||||
@@ -19,9 +19,26 @@ MODEL_FIELDS = {
|
||||
"timeout_seconds",
|
||||
"max_retries",
|
||||
"retry_interval_seconds",
|
||||
"rate_limit_retry_seconds",
|
||||
"tool_fallback_model",
|
||||
"tool_fallback_reasoning_effort",
|
||||
"tool_fallback_max_retries",
|
||||
"tool_fallback_no_progress_attempts",
|
||||
"reasoning_effort",
|
||||
}
|
||||
CAPABILITY_FIELDS = {"video_enabled", "outputter_enabled", "step_summarizer_enabled"}
|
||||
CAPABILITY_FIELDS = {
|
||||
"video_enabled",
|
||||
"recording_enabled",
|
||||
"outputter_enabled",
|
||||
"step_summarizer_enabled",
|
||||
"screenshot_limit_enabled",
|
||||
"task_timeout_seconds",
|
||||
"observation_timeout_seconds",
|
||||
"observation_max_attempts",
|
||||
"device_command_timeout_seconds",
|
||||
"action_failure_attempts",
|
||||
"worker_cleanup_timeout_seconds",
|
||||
}
|
||||
|
||||
|
||||
def _id(value: str | None) -> str:
|
||||
@@ -122,9 +139,16 @@ async def mobile_config(
|
||||
Only set persist_defaults=true when the user requests saved/global configuration.
|
||||
model changes: model (preset or custom), base_url, reasoning_effort
|
||||
(default/none/minimal/low/medium/high/xhigh/max), timeout_seconds, max_retries,
|
||||
retry_interval_seconds. 'default' omits reasoning; unsupported values fail visibly.
|
||||
capabilities: video_enabled (recording + analysis), outputter_enabled,
|
||||
step_summarizer_enabled. reports: formats=['docx','xlsx'] (at least one).
|
||||
retry_interval_seconds, rate_limit_retry_seconds, tool_fallback_model,
|
||||
tool_fallback_reasoning_effort, tool_fallback_max_retries,
|
||||
tool_fallback_no_progress_attempts. 'default' omits reasoning;
|
||||
unsupported values fail visibly.
|
||||
capabilities: video_enabled and recording_enabled are independent; outputter_enabled,
|
||||
step_summarizer_enabled, screenshot_limit_enabled, task_timeout_seconds (default 1800),
|
||||
observation_timeout_seconds (60), observation_max_attempts (3),
|
||||
device_command_timeout_seconds (10), action_failure_attempts (3),
|
||||
worker_cleanup_timeout_seconds (30).
|
||||
reports: formats=['docx','xlsx'] (at least one).
|
||||
API keys are never accepted or returned: use the Web UI or .env for credentials.
|
||||
Saving settings does not change an already running task. Flash/Pro are execution
|
||||
profiles, not model names. Model retries do not repeat device test cases.
|
||||
|
||||
@@ -35,6 +35,10 @@ from artemis.runtime import (
|
||||
submit_task_to_daemon,
|
||||
trace_store,
|
||||
)
|
||||
from artemis.runtime.task_limits import ExecutionBudget, task_timeout
|
||||
from artemis.runtime.task_control import read_state
|
||||
from artemis.runtime.task_result import save_failure_result, read_worker_outcome
|
||||
from artemis.config.settings import settings
|
||||
|
||||
# Seconds the spawned runner gets to finish its imports and open its log files
|
||||
# before the spawn is declared dead. Normal startup creates stdout.log within a
|
||||
@@ -44,6 +48,54 @@ from artemis.runtime import (
|
||||
SPAWN_WATCHDOG_SECONDS = 60
|
||||
|
||||
|
||||
def _watch_execution(proc, trace_id: str, limit: int, cleanup_limit: float | None = None) -> None:
|
||||
"""Supervise standalone MCP workers without charging device queue or pause time."""
|
||||
budget = ExecutionBudget(limit)
|
||||
finalized_at = None
|
||||
cleanup_limit = cleanup_limit or settings.ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS
|
||||
while proc.poll() is None:
|
||||
status = trace_store.read_status(trace_id) or {}
|
||||
outcome = read_worker_outcome(trace_id)
|
||||
if outcome and status.get("status") not in {
|
||||
"completed",
|
||||
"success",
|
||||
"failed",
|
||||
"cancelled",
|
||||
"interrupted",
|
||||
}:
|
||||
try:
|
||||
trace_store.update_trace_status(
|
||||
trace_id, outcome["task_status"], error=outcome.get("error")
|
||||
)
|
||||
except OSError:
|
||||
logging.exception("Cannot persist terminal status for %s", trace_id)
|
||||
if outcome or status.get("status") in {
|
||||
"completed",
|
||||
"success",
|
||||
"failed",
|
||||
"cancelled",
|
||||
"interrupted",
|
||||
}:
|
||||
finalized_at = finalized_at if finalized_at is not None else time.monotonic()
|
||||
if time.monotonic() - finalized_at >= cleanup_limit:
|
||||
_kill_process_tree(proc.pid)
|
||||
return
|
||||
time.sleep(0.5)
|
||||
continue
|
||||
control_status = read_state(trace_id).get("status")
|
||||
remaining = budget.update(excluded=control_status in {"queued", "task_paused", "resuming"})
|
||||
if remaining <= 0:
|
||||
error = f"任务执行超时({limit} 秒,不含暂停/排队时间)"
|
||||
_kill_process_tree(proc.pid)
|
||||
try:
|
||||
save_failure_result(trace_id, error, code="task_timeout")
|
||||
trace_store.update_trace_status(trace_id, "failed", error=error)
|
||||
except OSError:
|
||||
logging.exception("Cannot persist timeout report for %s", trace_id)
|
||||
return
|
||||
time.sleep(0.5)
|
||||
|
||||
|
||||
def _kill_process_tree(pid: int) -> None:
|
||||
"""Terminate a spawned runner and its children (the venv shim re-execs python)."""
|
||||
if sys.platform == "win32":
|
||||
@@ -212,6 +264,7 @@ def mobile_run_task(
|
||||
device_serial: str | None = None,
|
||||
verification_level: str | None = None,
|
||||
explorer_mode: str | None = None,
|
||||
runtime_overrides: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Starts an autonomous mobile UI automation subagent on a connected Android device.
|
||||
|
||||
@@ -277,7 +330,25 @@ def mobile_run_task(
|
||||
by the Operator: `"flash"` (1-shot detection, the default),
|
||||
`"pro"` (3-turn ReAct), `"ultra"` (deep pixel reasoning; slowest).
|
||||
Ignored for Flash.
|
||||
runtime_overrides: Optional TaskOptions JSON for this task only. Supports
|
||||
timeout_seconds (total execution, default 1800; excludes queue/pause),
|
||||
observation_timeout_seconds (60), observation_max_attempts (3),
|
||||
device_command_timeout_seconds (10), action_failure_attempts (3),
|
||||
worker_cleanup_timeout_seconds (30),
|
||||
model (API model name), reasoning_effort, request timeout/retries,
|
||||
recording and video analysis. Unknown fields and credentials are rejected.
|
||||
"""
|
||||
if runtime_overrides:
|
||||
from apps.admin_console.services.task_import import TaskOptions
|
||||
|
||||
runtime_overrides = TaskOptions.model_validate(runtime_overrides).model_dump(
|
||||
exclude_none=True
|
||||
)
|
||||
model = runtime_overrides.get("profile") or model
|
||||
device_serial = runtime_overrides.get("device_serial") or device_serial
|
||||
locked_app_package = runtime_overrides.get("locked_app_package") or locked_app_package
|
||||
verification_level = runtime_overrides.get("verification_level") or verification_level
|
||||
explorer_mode = runtime_overrides.get("explorer_mode") or explorer_mode
|
||||
# 0. Validate and normalize model
|
||||
if model.lower() not in ("flash", "pro"):
|
||||
raise ValueError(f"Invalid model '{model}'. Must be either 'Flash' or 'Pro'.")
|
||||
@@ -325,6 +396,7 @@ def mobile_run_task(
|
||||
conversation_id=conversation_id,
|
||||
verification_level=verification_level,
|
||||
explorer_mode=explorer_mode,
|
||||
runtime_overrides=runtime_overrides,
|
||||
base_url=base_url,
|
||||
)
|
||||
if resp and resp.get("status") == "rejected":
|
||||
@@ -475,6 +547,26 @@ def mobile_run_task(
|
||||
env = os.environ.copy()
|
||||
env["ARTEMIS_SESSION_ID"] = trace_id
|
||||
env["ARTEMIS_TASK_INGRESS"] = "mcp"
|
||||
env["ARTEMIS_TASK_WORKER"] = "1"
|
||||
for option, variable in {
|
||||
"model": "OPENAI_MODEL",
|
||||
"reasoning_effort": "OPENAI_REASONING_EFFORT",
|
||||
"request_timeout_seconds": "OPENAI_TIMEOUT_SECONDS",
|
||||
"max_retries": "OPENAI_MAX_RETRIES",
|
||||
"retry_interval_seconds": "OPENAI_RETRY_INTERVAL_SECONDS",
|
||||
"record_video": "ARTEMIS_RECORDING_ENABLED",
|
||||
"analyze_video": "ARTEMIS_VIDEO_ENABLED",
|
||||
"observation_timeout_seconds": "ARTEMIS_OBSERVATION_TIMEOUT_SECONDS",
|
||||
"observation_max_attempts": "ARTEMIS_OBSERVATION_MAX_ATTEMPTS",
|
||||
"device_command_timeout_seconds": "ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS",
|
||||
"action_failure_attempts": "ARTEMIS_ACTION_FAILURE_ATTEMPTS",
|
||||
}.items():
|
||||
if (runtime_overrides or {}).get(option) is not None:
|
||||
env[variable] = (
|
||||
str(runtime_overrides[option]).lower()
|
||||
if isinstance(runtime_overrides[option], bool)
|
||||
else str(runtime_overrides[option])
|
||||
)
|
||||
if device_serial:
|
||||
env["ADB_DEVICE_SERIAL"] = device_serial
|
||||
env["ARTEMIS_DEVICE_ID"] = device_serial
|
||||
@@ -519,6 +611,17 @@ def mobile_run_task(
|
||||
# interpreter startup and must be killed instead of squatting on the
|
||||
# queue as 'running' forever.
|
||||
_start_spawn_watchdog(trace_id, proc.pid, queue_ticket, conversation_id)
|
||||
threading.Thread(
|
||||
target=_watch_execution,
|
||||
args=(
|
||||
proc,
|
||||
trace_id,
|
||||
task_timeout({"runtime_overrides": runtime_overrides}),
|
||||
(runtime_overrides or {}).get("worker_cleanup_timeout_seconds"),
|
||||
),
|
||||
daemon=True,
|
||||
name=f"artemis-task-budget-{trace_id[:8]}",
|
||||
).start()
|
||||
|
||||
trace_dir = trace_store.get_trace_dir(trace_id)
|
||||
stdout_log_path = os.path.join(trace_dir, "stdout.log")
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
|
||||
[project]
|
||||
name = "artemis"
|
||||
version = "1.2.3"
|
||||
version = "1.3.2"
|
||||
description = "Autonomous AI Assistant for Mobile Automation"
|
||||
|
||||
authors = [
|
||||
|
||||
+2
-2
@@ -356,10 +356,10 @@ if (-not (Test-Path -LiteralPath (Join-Path $RootDir '.env'))) {
|
||||
}
|
||||
}
|
||||
if (-not $SkipMcpSync) {
|
||||
Write-Host (Get-LaunchText ' [CHECK] Installing/updating MCP for detected AI clients...' ' [检查] 正在为检测到的 AI 客户端安装或更新 MCP...') -ForegroundColor Cyan
|
||||
Write-Host (Get-LaunchText ' [CHECK] Installing/updating Codex MCP...' ' [检查] 正在安装或更新 Codex MCP...') -ForegroundColor Cyan
|
||||
& $ArtemisPython (Join-Path $ScriptDir 'update_installation.py') mcp
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host (Get-LaunchText ' [ERROR] MCP synchronization failed. Fix the configuration above, or use -SkipMcpSync to run the web UI without MCP.' ' [错误] MCP 同步失败。请修复上方配置;仅需启动网页时可使用 -SkipMcpSync。') -ForegroundColor Red
|
||||
Write-Host (Get-LaunchText ' [ERROR] Codex MCP synchronization failed. Fix the configuration above, or use -SkipMcpSync to run the web UI without MCP.' ' [错误] Codex MCP 同步失败。请修复上方配置;仅需启动网页时可使用 -SkipMcpSync。') -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
} else {
|
||||
|
||||
+62
-189
@@ -1,4 +1,4 @@
|
||||
"""Check an existing checkout and synchronize installed local MCP clients."""
|
||||
"""Check an existing checkout and synchronize the local Codex MCP configuration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -184,54 +184,6 @@ def _updated_entry(config: dict, template: dict, root: Path, names: tuple[str, .
|
||||
return updated
|
||||
|
||||
|
||||
def refresh_json(
|
||||
path: Path,
|
||||
keys: tuple[str, ...],
|
||||
root: Path,
|
||||
python: str,
|
||||
names: tuple[str, ...],
|
||||
template: dict | None = None,
|
||||
install_missing: bool = False,
|
||||
) -> bool | None:
|
||||
if not path.is_file() and not install_missing:
|
||||
return False
|
||||
raw = path.read_text(encoding="utf-8") if path.is_file() else "{}"
|
||||
data = json.loads(raw)
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("MCP configuration must be a JSON object")
|
||||
parent = data
|
||||
for key in keys:
|
||||
child = parent.get(key)
|
||||
if child is None and install_missing:
|
||||
child = parent[key] = {}
|
||||
if not isinstance(child, dict):
|
||||
raise ValueError(f"Invalid MCP configuration section: {key}")
|
||||
parent = child
|
||||
config = parent.get("artemis")
|
||||
if config is None and install_missing:
|
||||
if template is None:
|
||||
raise ValueError("A standard MCP template is required for installation")
|
||||
updated = template
|
||||
elif isinstance(config, dict) and _is_standard_local_artemis(config):
|
||||
if template is None:
|
||||
template = {
|
||||
"command": python,
|
||||
"args": ["-m", "mcp_server"],
|
||||
"env": {"PYTHONPATH": str(root)},
|
||||
}
|
||||
updated = _updated_entry(config, template, root, names)
|
||||
elif config is not None and not isinstance(config, dict):
|
||||
raise ValueError("The Artemis MCP configuration is not an object")
|
||||
else:
|
||||
return None
|
||||
if updated == config:
|
||||
return False
|
||||
parent["artemis"] = updated
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
_atomic_write(path, json.dumps(data, ensure_ascii=False, indent=2) + "\n")
|
||||
return True
|
||||
|
||||
|
||||
def _toml_value(value: object) -> str:
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
@@ -363,152 +315,73 @@ def refresh_codex(
|
||||
|
||||
|
||||
def refresh_mcp(root: Path) -> int:
|
||||
"""Synchronize only Codex; never inspect or modify other clients."""
|
||||
sys.path.insert(0, str(root))
|
||||
from artemis.interfaces.cli.commands.mcp import (
|
||||
ARTEMIS_MCP_TOOLS,
|
||||
_get_config_snippet,
|
||||
_get_vscode_user_dir,
|
||||
)
|
||||
from artemis.interfaces.cli.commands.mcp import ARTEMIS_MCP_TOOLS, _get_config_snippet
|
||||
|
||||
home = Path.home()
|
||||
vscode = _get_vscode_user_dir()
|
||||
appdata = Path(os.environ.get("APPDATA", home / "AppData/Roaming"))
|
||||
cline_store = vscode / "globalStorage/saoudrizwan.claude-dev"
|
||||
roo_store = vscode / "globalStorage/rooveterinaryinc.roo-cline"
|
||||
paths = [
|
||||
(home / ".gemini/config/mcp_config.json", ("mcpServers",), "antigravity", home / ".gemini"),
|
||||
(home / ".gemini/jetski/mcp_config.json", ("mcpServers",), "jetski", home / ".gemini"),
|
||||
(
|
||||
home / ".gemini/antigravity/mcp_config.json",
|
||||
("mcpServers",),
|
||||
"antigravity",
|
||||
home / ".gemini",
|
||||
),
|
||||
(home / ".cursor/mcp.json", ("mcpServers",), "cursor", home / ".cursor"),
|
||||
(
|
||||
home / ".codeium/windsurf/mcp_config.json",
|
||||
("mcpServers",),
|
||||
"windsurf",
|
||||
home / ".codeium/windsurf",
|
||||
),
|
||||
(home / ".claude.json", ("mcpServers",), "claude", home / ".claude"),
|
||||
(
|
||||
appdata / "Claude/claude_desktop_config.json",
|
||||
("mcpServers",),
|
||||
"claude",
|
||||
appdata / "Claude",
|
||||
),
|
||||
(vscode / "mcp.json", ("servers",), "vscode", vscode),
|
||||
(home / ".copilot/mcp-config.json", ("servers",), "vscode", home / ".copilot"),
|
||||
(
|
||||
home / ".cline/data/settings/cline_mcp_settings.json",
|
||||
("mcpServers",),
|
||||
"cline",
|
||||
home / ".cline",
|
||||
),
|
||||
(home / ".cline/mcp.json", ("mcpServers",), "cline", home / ".cline"),
|
||||
(
|
||||
cline_store / "settings/cline_mcp_settings.json",
|
||||
("mcpServers",),
|
||||
"cline",
|
||||
cline_store,
|
||||
),
|
||||
(
|
||||
roo_store / "settings/mcp_settings.json",
|
||||
("mcpServers",),
|
||||
"roo",
|
||||
roo_store,
|
||||
),
|
||||
(
|
||||
roo_store / "settings/cline_mcp_settings.json",
|
||||
("mcpServers",),
|
||||
"roo",
|
||||
roo_store,
|
||||
),
|
||||
(root / ".roo/mcp.json", ("mcpServers",), "roo", root / ".roo"),
|
||||
(home / ".openclaw/openclaw.json", ("mcp", "servers"), "openclaw", home / ".openclaw"),
|
||||
]
|
||||
codex_home = Path(os.environ.get("CODEX_HOME", home / ".codex"))
|
||||
paths.append((codex_home / "config.toml", (), "codex", codex_home))
|
||||
installed = updated_count = ready = skipped = errors = 0
|
||||
for path, keys, client, marker in paths:
|
||||
if not path.is_file() and not marker.is_dir():
|
||||
continue
|
||||
existed = path.is_file()
|
||||
template = _get_config_snippet(client, sys.executable, str(root))
|
||||
for key in keys:
|
||||
template = template[key]
|
||||
template = template["artemis"] if keys else template["mcp_servers"]["artemis"]
|
||||
try:
|
||||
changed = (
|
||||
refresh_json(
|
||||
path,
|
||||
keys,
|
||||
root,
|
||||
sys.executable,
|
||||
ARTEMIS_MCP_TOOLS,
|
||||
template=template,
|
||||
install_missing=True,
|
||||
)
|
||||
if keys
|
||||
else refresh_codex(
|
||||
path,
|
||||
root,
|
||||
sys.executable,
|
||||
ARTEMIS_MCP_TOOLS,
|
||||
template=template,
|
||||
install_missing=True,
|
||||
)
|
||||
)
|
||||
if changed:
|
||||
print(
|
||||
launch_text(
|
||||
f"[{'UPDATED' if existed else 'INSTALLED'}] MCP configuration: {path}",
|
||||
f"[{'已更新' if existed else '已安装'}] MCP 配置:{path}",
|
||||
)
|
||||
)
|
||||
if existed:
|
||||
updated_count += 1
|
||||
else:
|
||||
installed += 1
|
||||
elif changed is None:
|
||||
print(
|
||||
launch_text(
|
||||
f"[SKIP] Custom Artemis MCP entry in {path}; use the manual installer for this client.",
|
||||
f"[跳过] {path} 使用自定义 ARTEMIS MCP 启动方式;请手动检查此客户端。",
|
||||
)
|
||||
)
|
||||
skipped += 1
|
||||
else:
|
||||
ready += 1
|
||||
except (OSError, ValueError, TypeError, AttributeError):
|
||||
print(
|
||||
launch_text(
|
||||
f"[ERROR] Could not safely synchronize {path}; original configuration preserved.",
|
||||
f"[错误] 无法安全同步 {path};原配置已保留。",
|
||||
)
|
||||
)
|
||||
errors += 1
|
||||
print(
|
||||
launch_text(
|
||||
f"[CHECK] MCP clients: installed {installed}, updated {updated_count}, already current {ready}, custom skipped {skipped}, errors {errors}.",
|
||||
f"[检查] MCP 客户端:新装 {installed} 个、更新 {updated_count} 个、已是最新 {ready} 个、自定义跳过 {skipped} 个、失败 {errors} 个。",
|
||||
)
|
||||
)
|
||||
print(
|
||||
launch_text(
|
||||
"[ACTION] Reload or restart your AI client to load the current MCP process and tool schemas.",
|
||||
"[操作] 请在 AI 客户端重新加载 MCP 或重启客户端,使新版进程和工具签名生效。",
|
||||
)
|
||||
)
|
||||
if not (installed or updated_count or ready or skipped or errors):
|
||||
codex_home = Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex"))
|
||||
path = codex_home / "config.toml"
|
||||
print(launch_text(f"[CHECK] Codex MCP configuration: {path}", f"[检查] Codex MCP 配置:{path}"))
|
||||
if not path.is_file() and not codex_home.is_dir():
|
||||
print(
|
||||
launch_text(
|
||||
"[TIP] No supported client detected; install one with: uv run artemis mcp --install <client>",
|
||||
"[提示] 未检测到受支持的客户端;可执行 uv run artemis mcp --install <client>。",
|
||||
"[SKIP] Codex configuration directory not found. Install with: uv run artemis mcp --install codex",
|
||||
"[跳过] 未发现 Codex 配置目录。可执行 uv run artemis mcp --install codex 安装。",
|
||||
)
|
||||
)
|
||||
return 1 if errors else 0
|
||||
return 0
|
||||
existed = path.is_file()
|
||||
template = _get_config_snippet("codex", sys.executable, str(root))["mcp_servers"]["artemis"]
|
||||
try:
|
||||
changed = refresh_codex(
|
||||
path, root, sys.executable, ARTEMIS_MCP_TOOLS, template=template, install_missing=True
|
||||
)
|
||||
except (OSError, ValueError, TypeError, AttributeError) as exc:
|
||||
if isinstance(exc, OSError):
|
||||
reason = launch_text(
|
||||
"Cannot read/write the configuration; check file permissions.",
|
||||
"无法读写配置,请检查文件权限。",
|
||||
)
|
||||
else:
|
||||
reason = launch_text(
|
||||
"Invalid TOML or Artemis configuration structure; check the file in Codex.",
|
||||
"TOML 格式或 ARTEMIS 配置结构无效,请在 Codex 中检查配置文件。",
|
||||
)
|
||||
print(
|
||||
launch_text(
|
||||
f"[ERROR] Cannot safely synchronize {path}; original preserved. {reason}",
|
||||
f"[错误] 无法安全同步 {path};原配置已保留。{reason}",
|
||||
)
|
||||
)
|
||||
return 1
|
||||
if changed:
|
||||
print(
|
||||
launch_text(
|
||||
f"[{'UPDATED' if existed else 'INSTALLED'}] Codex MCP: {path}",
|
||||
f"[{'已更新' if existed else '已安装'}] Codex MCP:{path}",
|
||||
)
|
||||
)
|
||||
elif changed is None:
|
||||
print(
|
||||
launch_text(
|
||||
"[SKIP] Custom Codex Artemis MCP command preserved; check it manually.",
|
||||
"[跳过] Codex 使用自定义 ARTEMIS MCP 启动方式,已保留,请手动检查。",
|
||||
)
|
||||
)
|
||||
return 0
|
||||
else:
|
||||
print(
|
||||
launch_text(
|
||||
"[OK] Codex Artemis MCP is already current.", "[完成] Codex ARTEMIS MCP 已是最新。"
|
||||
)
|
||||
)
|
||||
print(
|
||||
launch_text(
|
||||
"[ACTION] Reload MCP or restart Codex to activate the updated tools.",
|
||||
"[操作] 请在 Codex 中重新加载 MCP 或重启 Codex,使新版进程和工具签名生效。",
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def frontend_fingerprint(root: Path) -> str:
|
||||
|
||||
@@ -5,6 +5,8 @@ description: 使用 ARTEMIS MCP 在 Android 真机或模拟器上完成自然语
|
||||
|
||||
# ARTEMIS 自动化
|
||||
|
||||
当前仅提供 Codex MCP 配置安装与更新:`uv run artemis mcp --install codex`;模板使用 `uv run artemis mcp --generate-config codex` 查看。启动日志会打印实际 Codex 配置路径,更新后重载 MCP。
|
||||
|
||||
使用项目提供的 MCP 工具操作 Android。报告由 ARTEMIS 自己生成,不要求宿主 AI 具备 Codex 私有工具、Word 或 Excel 插件。该 Skill 不提供桌面/浏览器自动化,也不提供历史动作脚本回放。
|
||||
|
||||
## 从需求到执行
|
||||
@@ -19,7 +21,7 @@ description: 使用 ARTEMIS MCP 在 Android 真机或模拟器上完成自然语
|
||||
## 结果、证据与交付
|
||||
|
||||
- 执行状态与业务判定分开:`completed` 只说明执行结束,不等于测试通过。检查工具返回的验证摘要、预期/实际结果,以及 `mobile_inspect_trace` 的步骤/截图。证据不足标记“未验证”;前置条件不满足说明阻塞原因,不能编造通过结论。
|
||||
- 用 `mobile_export_report(session_id=...)` 获取单任务报告;`formats=["screenshots"]` 获取排序截图,`["video"]` 获取保存的录屏分段,`["all"]` 获取 Word、Excel 和所有可用资料的 ZIP。批次传 `batch_id` 获取汇总和各项任务报告的 ZIP。未指定格式时使用保存的 Word/Excel 偏好。执行中的导出只是当前快照。
|
||||
- 用 `mobile_export_report(session_id=...)` 获取单任务报告;`formats=["screenshots"]` 获取排序截图,`["video"]` 获取保存的录屏分段,`["all"]` 获取 Word、Excel 和所有可用资料的 ZIP。批次传 `batch_id` 获取汇总、各项任务报告、步骤截图及已有录屏的 ZIP。未指定格式时使用保存的 Word/Excel 偏好。执行中的导出只是当前快照。
|
||||
- 工具返回 MCP 主机上的绝对文件路径和下载 URL。向用户交付真实生成的文件链接,并说明完成、失败、未验证、跳过数量及重要限制。不要只说“报告已生成”而不交付文件。
|
||||
- 用 `mobile_search_history` 按名称、目标、编号或标签搜索全部历史;日期先按用户时区转换为 Unix 秒,`start` 含边界、`end` 不含边界。按返回的 `total/page/size` 翻页,不能把第一页当作全部历史。
|
||||
- `mobile_inspect_trace` 是只读步骤证据查看;其中历史上下文的 replay 术语不表示重新执行设备动作。
|
||||
@@ -27,11 +29,17 @@ description: 使用 ARTEMIS MCP 在 Android 真机或模拟器上完成自然语
|
||||
## 配置和恢复边界
|
||||
|
||||
- `mobile_config` 读取非敏感设置。只有用户明确要求保存或修改默认配置时,才传 `changes` 与 `persist_defaults=true`;这些设置影响后续任务,不能为一次临时需求擅自改变以后所有任务。
|
||||
- 模型名称支持预设 `gpt-5.6-luna`、`mimo-v2.6-flash` 和自定义名称,取决于用户配置的 Responses 网关。思考强度最高支持 `max`,默认 `high`、单次请求 300 秒、失败后最多重试 5 次、固定间隔 10 秒;默认关闭视频分析与录屏。不根据模型名称猜测网关能力。
|
||||
- 模型名称支持预设 `gpt-5.6-luna`、`gpt-6-luna`、`mimo-v2.6-flash`、`mimo-v2.6-pro` 和自定义名称,取决于用户配置的 Responses 网关。思考强度最高支持 `max`,默认 `high`、单次请求 300 秒、失败后最多重试 5 次;普通错误每次等待 10 秒,429 每次等待 60 秒。首选模型连续 3 次未形成有效工具调用时,当前步骤改用 `gpt-6-luna`(high),兜底最多再尝试 3 次;下一步恢复首选模型。默认关闭视频分析与录屏。不根据模型名称猜测网关能力。
|
||||
- 强度选 `default` 表示不发送该参数,和 `none`(显式要求关闭)不同。接口拒绝强度参数时明确呈现失败,先询问用户是否改用 `default`,不能静默降级或换模型。
|
||||
- API Key 只通过网页或 `.env` 配置。不要请求用户把 key 发到聊天中,不读取/回显已有 key。保存后新任务生效;直接编辑环境文件后需重启相应服务,运行中的任务保留自己的配置。
|
||||
- 录屏与 AI 视频分析分别配置。批量任务的 `task_config` 可覆盖单项,不修改全局设置;只开视频分析时录制内容不作为可下载文件保存。暂停时停止录屏,恢复后按片段继续,不能把其他任务画面混入该任务资料。
|
||||
- 429 等待和兜底参数可在网页模型配置或经用户明确授权的 `mobile_config(section="model", persist_defaults=true)` 保存;当前任务不热切换。录屏与 AI 视频分析分别配置。批量任务的 `task_config` 可覆盖单项,不修改全局设置;只开视频分析时录制内容不作为可下载文件保存。暂停时停止录屏,恢复后按片段继续,不能把其他任务画面混入该任务资料。
|
||||
- 请求重试是模型调用恢复,不是测试用例重跑。批次默认失败继续、不自动重跑;服务重启后保留状态并暂停,用户明确要求后才继续或复制失败项。
|
||||
- 批次暂停只停止后续派发;当前任务等待指导时用 `mobile_manage_task(action="pause", trace_id=...)`,真正取消用 `stop`。用户明确要求删除批次记录时先 `mobile_batch(action="get")` 核对编号,再用 `delete`;执行中/排队中的批次不能删,删除会清除该批次的任务历史、报告和文件。服务重启后运行/暂停的任务标记中断,不自动重跑。停止可能保留已经产生的业务效果,不声称自动回滚。
|
||||
|
||||
需要编写外部可执行测试代码时,先通过 ARTEMIS 实际探索目标应用,确认页面、定位器和转换。优先动态定位,框架支持时才以已验证坐标兜底;在最终脚本中明确等待条件,不将 AI 思考耗时当作确定性等待。
|
||||
|
||||
执行保护默认总时限 1800 秒,单任务、批次和 MCP 都适用,排队及用户暂停不计时。设备观测默认 60 秒、最多 3 次,ADB 命令默认 10 秒;观测失败不会继续向模型发送占位图或旧元素索引。Flash 连续 3 次动作失败先重新观察再切换兜底,兜底仍失败则结束任务并保存原因。不能以提高步数或反复重提任务代替设备诊断。
|
||||
|
||||
`mobile_run_task(runtime_overrides={"timeout_seconds":1800,"observation_timeout_seconds":60,"observation_max_attempts":3,"device_command_timeout_seconds":10,"action_failure_attempts":3})` 可为单任务覆盖执行保护;其中 `model` 是 API 模型名,外层 `model` 仍是 Flash/Pro 模式。相同字段也支持批次的 `task_config`。全局默认通过网页功能配置或明确授权的 `mobile_config(section="capabilities", changes={"task_timeout_seconds":1800}, persist_defaults=true)` 保存,对后续任务生效,不接受密钥。
|
||||
|
||||
任务已经产生最终结果,但 SDK 线程或资源清理仍卡住时,默认最多再等待 30 秒后回收工作进程,避免继续占用队列。全局可用 `ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS` 或功能配置的 `worker_cleanup_timeout_seconds` 调整,任务 JSON 同名字段可覆盖。原有结果与截图保留,未完成保存的录屏尽力恢复。
|
||||
@@ -60,6 +60,6 @@
|
||||
|
||||
## 报告
|
||||
|
||||
`mobile_export_report(batch_id=...)` 下载批次 ZIP,包含汇总和各项报告,格式由创建批次时的设置决定。单任务传 `session_id`(单任务 MCP 的 `trace_id`)以及可选 `formats=["docx"]`、`["screenshots"]`、`["video"]` 或 `["all"]`。默认格式通过 `mobile_config(section="reports")` 查看。录屏与 AI 视频分析独立,只有保存了录屏才可下载片段。
|
||||
`mobile_export_report(batch_id=...)` 下载批次 ZIP,包含汇总、各项报告和有序步骤截图;任务保存了录屏时,还包含对应录屏片段。报告格式由创建批次时的设置决定。单任务传 `session_id`(单任务 MCP 的 `trace_id`)以及可选 `formats=["docx"]`、`["screenshots"]`、`["video"]` 或 `["all"]`。默认格式通过 `mobile_config(section="reports")` 查看。录屏与 AI 视频分析独立,只有保存了录屏才可下载片段。
|
||||
|
||||
结果文件在 MCP 主机的 `artifacts/mcp_exports/`。不要把 Office 文件内容当作文本塞入上下文;先检查工具返回的结构化结论,需要进一步审阅时再用宿主具备的文档工具打开文件。失败和未验证项也必须交付,保持原始证据。
|
||||
+2
-2
@@ -25,9 +25,9 @@ echo -SetupOnly 安装或检查环境,不启动网页
|
||||
echo -Port 8001 使用指定端口,默认 8000
|
||||
echo -NoOpen 启动服务后不自动打开浏览器
|
||||
echo -SkipUpdate 跳过 Git 源码更新检查
|
||||
echo -SkipMcpSync 仅启动网页,跳过 MCP 安装和更新
|
||||
echo -SkipMcpSync 仅启动网页,跳过 Codex MCP 安装和更新
|
||||
echo -Help 显示本说明
|
||||
echo.
|
||||
echo 国内镜像设置:config\downloads.json
|
||||
echo 首次运行需联网;后续运行会检查源码、依赖,并同步已安装 AI 客户端的 MCP。
|
||||
echo 首次运行需联网;后续运行会检查源码、依赖,并同步 Codex MCP 配置。
|
||||
endlocal & exit /b 0
|
||||
@@ -24,7 +24,7 @@ if /i "%ARTEMIS_LAUNCH_LANG%"=="zh-CN" (echo ARTEMIS 手机自动化测试
|
||||
echo ======================================================
|
||||
echo.
|
||||
|
||||
rem First run installs uv/Python/.venv; later runs check Git, dependencies and existing MCP clients.
|
||||
rem First run installs uv/Python/.venv; later runs check Git, dependencies and Codex MCP.
|
||||
rem Use -SkipUpdate to skip the Git source check and run local code.
|
||||
rem Use start.bat -SetupOnly to prepare Python without launching the UI.
|
||||
rem Use start-cn.bat or start.bat -Mirror China for China download mirrors.
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from io import BytesIO
|
||||
import json
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
from uuid import UUID
|
||||
@@ -108,6 +109,63 @@ def test_invalid_archive_and_empty_template():
|
||||
assert not task_import.parse_workbook(task_import.template())["valid"]
|
||||
|
||||
|
||||
def test_batch_archive_preparation_reports_progress_and_reuses_ready_file(
|
||||
client, isolated, monkeypatch
|
||||
):
|
||||
batch = service.create_batch(payload())
|
||||
batch_id = batch["id"]
|
||||
started = threading.Event()
|
||||
release = threading.Event()
|
||||
calls = []
|
||||
|
||||
def slow_export(identifier, progress=None):
|
||||
calls.append(identifier)
|
||||
progress(0, 2, "正在打包任务 1/2")
|
||||
started.set()
|
||||
assert release.wait(5)
|
||||
progress(2, 2, "正在完成压缩包")
|
||||
path = reports.EXPORT_ROOT / "batches" / identifier / "批次报告.zip"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(b"prepared archive")
|
||||
return path
|
||||
|
||||
monkeypatch.setattr(reports, "export_batch", slow_export)
|
||||
base = f"/api/automation/batches/{batch_id}/archive"
|
||||
try:
|
||||
response = client.post(base + "/prepare")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "preparing"
|
||||
assert started.wait(2)
|
||||
progress = client.get(base + "/status").json()
|
||||
assert progress["total"] == 2
|
||||
assert progress["stage"] == "正在打包任务 1/2"
|
||||
assert client.get(base).status_code == 409
|
||||
finally:
|
||||
release.set()
|
||||
for _ in range(50):
|
||||
if client.get(base + "/status").json()["status"] == "ready":
|
||||
break
|
||||
time.sleep(0.02)
|
||||
assert client.get(base + "/status").json()["status"] == "ready"
|
||||
assert client.get(base).content == b"prepared archive"
|
||||
assert calls == [batch_id]
|
||||
|
||||
|
||||
def test_completed_batch_reuses_existing_archive(client, isolated, monkeypatch):
|
||||
batch = service.create_batch(payload())
|
||||
batch_id = batch["id"]
|
||||
isolated.put("batch", batch_id, {**batch, "status": "completed", "report_ready": True})
|
||||
path = reports.EXPORT_ROOT / "batches" / batch_id / "批次报告.zip"
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_bytes(b"existing archive")
|
||||
exporter = Mock(side_effect=AssertionError("archive must not be rebuilt"))
|
||||
monkeypatch.setattr(reports, "export_batch", exporter)
|
||||
base = f"/api/automation/batches/{batch_id}/archive"
|
||||
assert client.post(base + "/prepare").json()["status"] == "ready"
|
||||
assert client.get(base).content == b"existing archive"
|
||||
exporter.assert_not_called()
|
||||
|
||||
|
||||
def test_preview_does_not_enqueue(client, isolated):
|
||||
response = client.post(
|
||||
"/api/automation/import/preview",
|
||||
@@ -242,6 +300,116 @@ def test_restart_never_repeats_started_work(isolated):
|
||||
assert isolated.jobs(retry["id"])[0]["session_id"] != job["session_id"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("liveness_source", ["pid", "device_lock"])
|
||||
def test_recovery_preserves_live_runner_and_batch(isolated, monkeypatch, liveness_source):
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
from artemis.runtime import DeviceExecutionLock
|
||||
|
||||
batch = service.create_batch(payload())
|
||||
service.batch_action(batch["id"], "resume")
|
||||
job = isolated.jobs(batch["id"])[0]
|
||||
isolated.save_job({**job, "status": "running"})
|
||||
if liveness_source == "pid":
|
||||
with sqlite3.connect(isolated.path) as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO sessions(session_id,initial_goal,start_time,status,device_info,pid) "
|
||||
"VALUES (?,?,?,?,?,?)",
|
||||
(job["session_id"], "goal", 1, "running", "{}", os.getpid()),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
DeviceExecutionLock,
|
||||
"get_active_owners",
|
||||
lambda: (
|
||||
{"device": SimpleNamespace(session_id=job["session_id"])}
|
||||
if liveness_source == "device_lock"
|
||||
else {}
|
||||
),
|
||||
)
|
||||
service.recover_batches()
|
||||
assert isolated.job(job["session_id"])["status"] == "running"
|
||||
assert not isolated.job(job["session_id"]).get("error")
|
||||
assert isolated.get("batch", batch["id"])["status"] == "running"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_natural_completion_resolves_old_recovery_error_in_job_report_and_history(
|
||||
client, isolated
|
||||
):
|
||||
job = isolated.jobs(service.create_batch(payload())["id"])[0]
|
||||
message = "服务中断;未完成任务需手动重跑"
|
||||
isolated.save_job({**job, "status": "interrupted", "error": message})
|
||||
await service.on_terminal({"session_id": job["session_id"]}, "completed")
|
||||
saved = isolated.job(job["session_id"])
|
||||
assert saved["status"] == "completed"
|
||||
assert not saved.get("error")
|
||||
assert saved["recovery_events"][0]["message"] == message
|
||||
assert reports.session_report(job["session_id"])["error"] == ""
|
||||
assert not client.get("/api/automation/history").json()["items"][0].get("error")
|
||||
|
||||
|
||||
def test_recovery_repairs_completed_legacy_warning_but_preserves_real_failure(isolated):
|
||||
batch = service.create_batch(payload())
|
||||
first, second = isolated.jobs(batch["id"])
|
||||
isolated.save_job({**first, "status": "completed", "error": "服务中断,需手动重跑"})
|
||||
isolated.save_job({**second, "status": "failed", "error": "任务执行超时(30 秒)"})
|
||||
assert reports.session_report(first["session_id"])["error"] == ""
|
||||
service.recover_batches()
|
||||
assert not isolated.job(first["session_id"]).get("error")
|
||||
assert "服务中断" in isolated.job(first["session_id"])["recovery_events"][0]["message"]
|
||||
assert "超时" in isolated.job(second["session_id"])["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_surviving_worker_is_finalized_when_it_exits_without_resubmission(
|
||||
isolated, monkeypatch
|
||||
):
|
||||
import os
|
||||
from artemis.runtime import DeviceExecutionLock
|
||||
|
||||
batch = service.create_batch(payload())
|
||||
service.batch_action(batch["id"], "resume")
|
||||
job = isolated.jobs(batch["id"])[0]
|
||||
isolated.save_job({**job, "status": "running"})
|
||||
monkeypatch.setattr(DeviceExecutionLock, "get_active_owners", lambda: {})
|
||||
with sqlite3.connect(isolated.path) as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO sessions(session_id,initial_goal,start_time,status,device_info,pid) "
|
||||
"VALUES (?,?,?,?,?,?)",
|
||||
(job["session_id"], "goal", time.time(), "running", "{}", os.getpid()),
|
||||
)
|
||||
service.recover_batches()
|
||||
await service.reconcile_recovered_jobs()
|
||||
assert isolated.job(job["session_id"])["status"] == "running"
|
||||
with sqlite3.connect(isolated.path) as conn:
|
||||
conn.execute(
|
||||
"UPDATE sessions SET pid=NULL,status='completed' WHERE session_id=?",
|
||||
(job["session_id"],),
|
||||
)
|
||||
await service.reconcile_recovered_jobs()
|
||||
saved = isolated.job(job["session_id"])
|
||||
assert saved["status"] == "completed"
|
||||
assert saved["report_ready"]
|
||||
assert not saved.get("recovery_waiting")
|
||||
assert not saved.get("error")
|
||||
assert len(isolated.jobs(batch["id"])) == 2
|
||||
assert isolated.jobs(batch["id"])[1]["status"] == "pending"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_surviving_worker_that_dies_without_result_is_interrupted(isolated, monkeypatch):
|
||||
from artemis.runtime import DeviceExecutionLock
|
||||
|
||||
job = isolated.jobs(service.create_batch(payload())["id"])[0]
|
||||
isolated.save_job({**job, "status": "running", "recovery_waiting": True})
|
||||
monkeypatch.setattr(DeviceExecutionLock, "get_active_owners", lambda: {})
|
||||
await service.reconcile_recovered_jobs()
|
||||
saved = isolated.job(job["session_id"])
|
||||
assert saved["status"] == "interrupted"
|
||||
assert "服务中断" in saved["error"]
|
||||
assert not saved.get("recovery_waiting")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_admission_idempotent_and_bound_device(isolated, monkeypatch):
|
||||
from apps.admin_console import server
|
||||
@@ -318,6 +486,38 @@ async def test_fail_fast_includes_skipped_reports(isolated, monkeypatch):
|
||||
assert sum(n.endswith(".docx") for n in z.namelist()) == 3
|
||||
|
||||
|
||||
def test_batch_archive_contains_each_task_media_when_present(isolated, monkeypatch, tmp_path):
|
||||
batch = service.create_batch(payload())
|
||||
first, second = isolated.jobs(batch["id"])
|
||||
image1 = tmp_path / "first.png"
|
||||
image2 = tmp_path / "second.png"
|
||||
video = tmp_path / "recording.mp4"
|
||||
image1.write_bytes(b"first screenshot")
|
||||
image2.write_bytes(b"second screenshot")
|
||||
video.write_bytes(b"saved recording")
|
||||
images = {first["session_id"]: image1, second["session_id"]: image2}
|
||||
monkeypatch.setattr(
|
||||
reports,
|
||||
"session_screenshots",
|
||||
lambda report: [(images[report["id"]], "0001_步骤0001_操作前.png")],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
reports,
|
||||
"session_videos",
|
||||
lambda report: (
|
||||
[(video, "0001_recording.mp4")] if report["id"] == first["session_id"] else []
|
||||
),
|
||||
)
|
||||
|
||||
archive = reports.export_batch(batch["id"])
|
||||
with zipfile.ZipFile(archive) as z:
|
||||
names = z.namelist()
|
||||
assert len([name for name in names if "/步骤截图/" in name]) == 2
|
||||
assert len([name for name in names if "/任务录屏/" in name]) == 1
|
||||
assert all(name.startswith("任务报告/") for name in names if "步骤截图" in name)
|
||||
assert z.read(next(name for name in names if "/任务录屏/" in name)) == b"saved recording"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_report_failure_does_not_strand_job(isolated, monkeypatch):
|
||||
job = isolated.jobs(service.create_batch(payload())["id"])[0]
|
||||
|
||||
@@ -23,10 +23,22 @@ def isolated_settings(monkeypatch, tmp_path):
|
||||
"OPENAI_TIMEOUT_SECONDS",
|
||||
"OPENAI_MAX_RETRIES",
|
||||
"OPENAI_RETRY_INTERVAL_SECONDS",
|
||||
"OPENAI_RATE_LIMIT_RETRY_SECONDS",
|
||||
"OPENAI_TOOL_FALLBACK_MODEL",
|
||||
"OPENAI_TOOL_FALLBACK_REASONING_EFFORT",
|
||||
"OPENAI_TOOL_FALLBACK_MAX_RETRIES",
|
||||
"ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS",
|
||||
"OPENAI_REASONING_EFFORT",
|
||||
"ARTEMIS_VIDEO_ENABLED",
|
||||
"ARTEMIS_OUTPUTTER_ENABLED",
|
||||
"ARTEMIS_STEP_SUMMARIZER_ENABLED",
|
||||
"ARTEMIS_SCREENSHOT_LIMIT_ENABLED",
|
||||
"ARTEMIS_TASK_TIMEOUT_SECONDS",
|
||||
"ARTEMIS_OBSERVATION_TIMEOUT_SECONDS",
|
||||
"ARTEMIS_OBSERVATION_MAX_ATTEMPTS",
|
||||
"ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS",
|
||||
"ARTEMIS_ACTION_FAILURE_ATTEMPTS",
|
||||
"ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS",
|
||||
]
|
||||
for key in keys:
|
||||
monkeypatch.setattr(settings, key, getattr(settings, key))
|
||||
@@ -42,6 +54,11 @@ def test_save_reload_and_blank_key_preserves_secret(isolated_settings):
|
||||
api_key="new-test-secret",
|
||||
timeout_seconds=75,
|
||||
max_retries=2,
|
||||
rate_limit_retry_seconds=90,
|
||||
tool_fallback_model="custom/fallback",
|
||||
tool_fallback_reasoning_effort="max",
|
||||
tool_fallback_max_retries=2,
|
||||
tool_fallback_no_progress_attempts=4,
|
||||
)
|
||||
result = service.save_model_settings(update)
|
||||
assert result["api_key_configured"]
|
||||
@@ -49,12 +66,16 @@ def test_save_reload_and_blank_key_preserves_secret(isolated_settings):
|
||||
saved = dotenv_values(isolated_settings)
|
||||
assert saved["OPENAI_API_KEY"] == "new-test-secret"
|
||||
assert saved["OPENAI_MODEL"] == "my-model"
|
||||
assert saved["OPENAI_RATE_LIMIT_RETRY_SECONDS"] == "90.0"
|
||||
assert saved["OPENAI_TOOL_FALLBACK_MODEL"] == "custom/fallback"
|
||||
assert saved["ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS"] == "4"
|
||||
assert saved["UNRELATED"] == "keep"
|
||||
assert "# user settings" in isolated_settings.read_text(encoding="utf-8")
|
||||
reloaded = Settings(_env_file=isolated_settings)
|
||||
assert reloaded.OPENAI_MODEL == "my-model"
|
||||
assert parse_llm_config().operator.model == "my-model"
|
||||
assert parse_llm_config().operator.timeout == 75
|
||||
assert parse_llm_config().operator.fallback.model == "custom/fallback"
|
||||
service.save_model_settings(update.model_copy(update={"api_key": SecretStr("")}))
|
||||
assert dotenv_values(isolated_settings)["OPENAI_API_KEY"] == "new-test-secret"
|
||||
|
||||
@@ -93,21 +114,38 @@ def test_rejects_invalid_base_url(url):
|
||||
def test_capabilities_persist_and_apply_to_new_tasks(isolated_settings):
|
||||
result = service.save_capability_settings(
|
||||
service.CapabilitySettings(
|
||||
video_enabled=False, outputter_enabled=False, step_summarizer_enabled=False
|
||||
video_enabled=False,
|
||||
outputter_enabled=False,
|
||||
step_summarizer_enabled=False,
|
||||
screenshot_limit_enabled=False,
|
||||
)
|
||||
)
|
||||
assert result.video_enabled is False
|
||||
saved = dotenv_values(isolated_settings)
|
||||
assert saved["ARTEMIS_VIDEO_ENABLED"] == "false"
|
||||
assert saved["ARTEMIS_SCREENSHOT_LIMIT_ENABLED"] == "false"
|
||||
config = load_agent_config()
|
||||
assert config.video_analyzer.enabled is False
|
||||
assert config.pro.video_analyzer.enabled is False
|
||||
assert config.outputter.enabled is False
|
||||
assert config.flash.step_summarizer.enabled is False
|
||||
assert config.screenshot.enabled is False
|
||||
service.save_capability_settings(service.CapabilitySettings(video_enabled=True))
|
||||
assert load_agent_config().video_analyzer.enabled is True
|
||||
|
||||
|
||||
def test_screenshot_json_can_override_environment(monkeypatch, tmp_path):
|
||||
config_path = tmp_path / "artemis.jsonc"
|
||||
config_path.write_text(
|
||||
'{"agent": {"screenshot": {"enabled": true, "max_long_edge": 1024}}}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("ARTEMIS_SCREENSHOT_LIMIT_ENABLED", "false")
|
||||
screenshot = load_agent_config(config_path).screenshot
|
||||
assert screenshot.enabled is True
|
||||
assert screenshot.max_long_edge == 1024
|
||||
|
||||
|
||||
def test_failed_write_keeps_running_settings(monkeypatch, isolated_settings):
|
||||
previous = settings.OPENAI_MODEL
|
||||
monkeypatch.setattr(
|
||||
@@ -170,3 +208,26 @@ def test_responses_payload_has_reasoning_and_no_sdk_retry(monkeypatch, effort):
|
||||
assert factory.call_args.kwargs["use_responses_api"] is True
|
||||
ModelFactory.create_model(ModelEndpoint(api_key="test-only", reasoning_effort="default"))
|
||||
assert "reasoning_effort" not in factory.call_args.kwargs
|
||||
|
||||
|
||||
def test_execution_protection_saves_reload_without_touching_secret(isolated_settings):
|
||||
result = service.save_capability_settings(
|
||||
service.CapabilitySettings(
|
||||
task_timeout_seconds=3600,
|
||||
observation_timeout_seconds=45,
|
||||
observation_max_attempts=2,
|
||||
device_command_timeout_seconds=8,
|
||||
action_failure_attempts=4,
|
||||
worker_cleanup_timeout_seconds=15,
|
||||
)
|
||||
)
|
||||
reloaded = Settings(_env_file=isolated_settings)
|
||||
assert reloaded.ARTEMIS_TASK_TIMEOUT_SECONDS == result.task_timeout_seconds == 3600
|
||||
assert reloaded.ARTEMIS_OBSERVATION_TIMEOUT_SECONDS == 45
|
||||
assert reloaded.ARTEMIS_OBSERVATION_MAX_ATTEMPTS == 2
|
||||
assert reloaded.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS == 8
|
||||
assert reloaded.ARTEMIS_ACTION_FAILURE_ATTEMPTS == 4
|
||||
assert reloaded.ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS == 15
|
||||
assert reloaded.OPENAI_API_KEY is None
|
||||
assert settings.OPENAI_API_KEY.get_secret_value() == "existing-test-secret"
|
||||
assert dotenv_values(isolated_settings)["UNRELATED"] == "keep"
|
||||
@@ -15,6 +15,7 @@
|
||||
import asyncio
|
||||
from contextlib import suppress
|
||||
import signal
|
||||
import socket
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -23,10 +24,55 @@ import uvicorn
|
||||
from apps.admin_console.core.state import state
|
||||
from apps.admin_console.routers.tasks import stream_events
|
||||
from apps.admin_console.server import ArtemisUvicornServer, app, on_shutdown
|
||||
from apps.admin_console import server as server_module
|
||||
|
||||
WINDOWS_FORCE_SIGNAL = getattr(signal, "SIGBREAK", signal.SIGTERM)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reload", [False, True])
|
||||
def test_occupied_port_never_initializes_or_overwrites_server_metadata(
|
||||
tmp_path, monkeypatch, reload
|
||||
):
|
||||
from artemis.runtime import server_lifecycle
|
||||
|
||||
metadata = tmp_path / "server.json"
|
||||
metadata.write_text('{"pid":123,"lifecycle_token":"original"}', encoding="utf-8")
|
||||
monkeypatch.setattr(server_lifecycle, "get_server_info_file", lambda: metadata)
|
||||
before = metadata.read_bytes()
|
||||
with (
|
||||
socket.create_server(("127.0.0.1", 0)) as original,
|
||||
patch.object(server_module.ArtemisUvicornServer, "run") as run,
|
||||
patch.object(server_module, "write_server_info") as publish,
|
||||
):
|
||||
with pytest.raises(SystemExit, match="1"):
|
||||
server_module.run_ui_server("127.0.0.1", original.getsockname()[1], reload=reload)
|
||||
run.assert_not_called()
|
||||
publish.assert_not_called()
|
||||
assert metadata.read_bytes() == before
|
||||
|
||||
|
||||
def test_server_holds_port_before_running_lifespan(monkeypatch):
|
||||
monkeypatch.setattr(state, "port", 0, raising=False)
|
||||
monkeypatch.setattr(state, "host", "127.0.0.1", raising=False)
|
||||
observed = []
|
||||
|
||||
def run(_server, sockets):
|
||||
listener = sockets[0]
|
||||
port = listener.getsockname()[1]
|
||||
observed.append(listener)
|
||||
assert state.port == port
|
||||
with pytest.raises(OSError), socket.create_server(("127.0.0.1", port)):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(server_module.ArtemisUvicornServer, "run", run)
|
||||
monkeypatch.setattr(server_module, "clear_server_info", lambda **kwargs: None)
|
||||
with patch.object(server_module, "write_server_info") as publish:
|
||||
server_module.run_ui_server("127.0.0.1", 0)
|
||||
publish.assert_not_called() # Publication belongs to the later lifespan.
|
||||
assert len(observed) == 1
|
||||
assert observed[0].fileno() == -1
|
||||
|
||||
|
||||
def _pending_queue_get_tasks():
|
||||
return {
|
||||
task
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
"""Unit tests for Universal FlashRunner."""
|
||||
|
||||
import base64
|
||||
from io import BytesIO
|
||||
from PIL import Image
|
||||
from artemis.mcp.observation import DeviceObservationError
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
import pytest
|
||||
@@ -72,9 +75,18 @@ def test_flash_runner_tools_initialization(mock_context):
|
||||
# --- Post-action screenshot fallback (failed action / unknown tool) ----------------
|
||||
|
||||
|
||||
def _post_image():
|
||||
buffer = BytesIO()
|
||||
Image.new("RGB", (64, 64), "white").save(buffer, format="PNG")
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
_POST_IMG = _post_image()
|
||||
|
||||
|
||||
def _screen(elements):
|
||||
return SimpleNamespace(
|
||||
base64=base64.b64encode(b"POST-IMG").decode("ascii"),
|
||||
base64=base64.b64encode(_POST_IMG).decode("ascii"),
|
||||
elements=elements,
|
||||
width=1080,
|
||||
height=2400,
|
||||
@@ -120,7 +132,7 @@ async def test_fallback_observation_renders_the_indexed_ui_list(mock_context, tm
|
||||
exec_result, "click", frozenset({"click"}), state
|
||||
)
|
||||
|
||||
assert post == b"POST-IMG"
|
||||
assert post == _POST_IMG
|
||||
assert isinstance(exec_result.ui_elements_text, str)
|
||||
assert exec_result.ui_elements_text == "[1] Text: 'Settings' | Bounds: [37,50][962,112]"
|
||||
assert "{'text'" not in exec_result.ui_elements_text
|
||||
@@ -154,8 +166,8 @@ async def test_fallback_observation_is_skipped_for_non_action_tools(mock_context
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_observation_keeps_the_executors_ui_list(mock_context, tmp_path):
|
||||
"""An action that reported a UI list but no screenshot keeps its own list."""
|
||||
async def test_fallback_observation_replaces_stale_executor_ui_list(mock_context, tmp_path):
|
||||
"""Fresh screenshots and element lists must describe the same observation."""
|
||||
with patch("artemis.controllers.unified_controller.get_driver"):
|
||||
runner = FlashRunner(mock_context, goal="g")
|
||||
runner.controller = Mock()
|
||||
@@ -175,12 +187,13 @@ async def test_fallback_observation_keeps_the_executors_ui_list(mock_context, tm
|
||||
exec_result, "click", frozenset({"click"}), None
|
||||
)
|
||||
|
||||
assert post == b"POST-IMG"
|
||||
assert exec_result.ui_elements_text == "[1] Text: 'Mine' | Bounds: [0,0][10,10]"
|
||||
assert post == _POST_IMG
|
||||
assert "Other" in exec_result.ui_elements_text
|
||||
assert "Mine" not in exec_result.ui_elements_text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_observation_failure_is_contained(mock_context):
|
||||
async def test_fallback_observation_failure_stops_after_bounded_recovery(mock_context):
|
||||
with patch("artemis.controllers.unified_controller.get_driver"):
|
||||
runner = FlashRunner(mock_context, goal="g")
|
||||
runner.controller = Mock()
|
||||
@@ -189,11 +202,13 @@ async def test_fallback_observation_failure_is_contained(mock_context):
|
||||
state.latest_screenshot = "old.jpg"
|
||||
exec_result = _failed_click()
|
||||
|
||||
post = await runner._capture_post_screenshot(exec_result, "click", frozenset({"click"}), state)
|
||||
|
||||
assert post is None
|
||||
assert exec_result.ui_elements_text is None
|
||||
assert state.latest_screenshot == "old.jpg"
|
||||
with patch("artemis.mcp.observation.settings.ARTEMIS_OBSERVATION_MAX_ATTEMPTS", 3):
|
||||
with pytest.raises(DeviceObservationError, match="adb gone"):
|
||||
await runner._capture_post_screenshot(exec_result, "click", frozenset({"click"}), state)
|
||||
assert runner.controller.get_screen_data.await_count == 3
|
||||
assert state.latest_screenshot is None
|
||||
assert state.indexed_elements == []
|
||||
assert state.indexed_points == []
|
||||
|
||||
|
||||
def test_flash_runner_executor_follows_the_flash_explorer_tier(mock_context):
|
||||
|
||||
@@ -213,7 +213,8 @@ def test_observation_tail_has_pro_shape(mock_context):
|
||||
texts = [b["text"] for b in later.content]
|
||||
assert OBSERVATION_HEADER_RE.match(texts[0])
|
||||
assert "objective" not in "".join(texts).lower()
|
||||
assert texts[1:] == [
|
||||
assert "当前没有可用的元素索引" in texts[1]
|
||||
assert texts[2:] == [
|
||||
"nudge",
|
||||
"[INJECTED]",
|
||||
'User instruction: "stop"',
|
||||
@@ -384,7 +385,7 @@ def _make_runner(mock_context, responses, max_turns=None):
|
||||
pending = list(responses)
|
||||
|
||||
async def _invoke(llm, tools, messages):
|
||||
runner.calls.append({"tools": list(tools), "messages": list(messages)})
|
||||
runner.calls.append({"llm": llm, "tools": list(tools), "messages": list(messages)})
|
||||
return pending.pop(0)
|
||||
|
||||
runner._invoke_model = AsyncMock(side_effect=_invoke)
|
||||
@@ -408,6 +409,98 @@ def _report(status="completed", tc_id="tc-report", **extra):
|
||||
_INITIAL_OBSERVATION = ("shot0.png", b"IMG0", "[1] Settings")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("fallback_succeeds", [True, False])
|
||||
async def test_failed_actions_have_finite_fallback_and_restore_primary(
|
||||
mock_context, monkeypatch, fallback_succeeds
|
||||
):
|
||||
monkeypatch.setattr("artemis.agents.flash.runner.settings.ARTEMIS_ACTION_FAILURE_ATTEMPTS", 3)
|
||||
monkeypatch.setattr(
|
||||
"artemis.agents.flash.runner.settings.ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS", 3
|
||||
)
|
||||
action_count = 4 if fallback_succeeds else 6
|
||||
responses = [
|
||||
AIMessage(
|
||||
content="try", tool_calls=[{"name": "click", "args": {"index": 1}, "id": f"tc-{i}"}]
|
||||
)
|
||||
for i in range(action_count)
|
||||
]
|
||||
if fallback_succeeds:
|
||||
responses.append(_report())
|
||||
with patch("artemis.controllers.unified_controller.get_driver"):
|
||||
runner = _make_runner(mock_context, responses)
|
||||
fallback = Mock()
|
||||
count = 0
|
||||
|
||||
def execute(name, args, tc_id, state, **kwargs):
|
||||
nonlocal count
|
||||
count += 1
|
||||
result = _exec_result(tc_id, name, b"current-screen")
|
||||
if count <= 3 or not fallback_succeeds:
|
||||
result.status = "error"
|
||||
result.text_summary = "目标不存在"
|
||||
return result
|
||||
|
||||
runner.executor.execute = AsyncMock(side_effect=execute)
|
||||
with (
|
||||
patch(
|
||||
"artemis.agents.flash.runner.capture_screenshot_and_parse_ui",
|
||||
AsyncMock(return_value=_INITIAL_OBSERVATION),
|
||||
) as observe,
|
||||
patch("artemis.agents.flash.runner.get_llm", return_value=fallback),
|
||||
):
|
||||
outcome = await runner.run(State(initial_goal="Open Settings"))
|
||||
primary = runner._init_llm.return_value
|
||||
assert all(call["llm"] is primary for call in runner.calls[:3])
|
||||
assert runner.calls[3]["llm"] is fallback
|
||||
assert observe.await_count == 2 # initial + fresh observation before fallback
|
||||
if fallback_succeeds:
|
||||
assert outcome["status"] == "completed"
|
||||
assert runner.calls[-1]["llm"] is primary
|
||||
else:
|
||||
assert outcome["status"] == "failed"
|
||||
assert "兜底模型连续 3" in outcome["explanation"]
|
||||
assert "目标不存在" in outcome["explanation"]
|
||||
assert len(runner.calls) == 6
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_current_ui_clears_previous_turn_element_list(mock_context):
|
||||
response = AIMessage(
|
||||
content="tap", tool_calls=[{"name": "click", "args": {"index": 1}, "id": "tc"}]
|
||||
)
|
||||
with patch("artemis.controllers.unified_controller.get_driver"):
|
||||
runner = _make_runner(mock_context, [response, _report()])
|
||||
runner.executor.execute = AsyncMock(
|
||||
return_value=_exec_result("tc", "click", b"fresh-screen", ui="")
|
||||
)
|
||||
with patch(
|
||||
"artemis.agents.flash.runner.capture_screenshot_and_parse_ui",
|
||||
AsyncMock(return_value=_INITIAL_OBSERVATION),
|
||||
):
|
||||
await runner.run(State(initial_goal="Open Settings"))
|
||||
tail = runner.calls[-1]["messages"][-1]
|
||||
text = "\n".join(block.get("text", "") for block in tail.content)
|
||||
assert "当前没有可用的元素索引" in text
|
||||
assert "[1] Settings" not in text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unavailable_screen_ends_before_any_model_call(mock_context):
|
||||
from artemis.mcp.observation import DeviceObservationError
|
||||
|
||||
with patch("artemis.controllers.unified_controller.get_driver"):
|
||||
runner = _make_runner(mock_context, [])
|
||||
with patch(
|
||||
"artemis.agents.flash.runner.capture_screenshot_and_parse_ui",
|
||||
AsyncMock(side_effect=DeviceObservationError("设备断连")),
|
||||
):
|
||||
outcome = await runner.run(State(initial_goal="Open Settings"))
|
||||
assert outcome["code"] == "device_observation_failed"
|
||||
assert outcome["error"] == "设备断连"
|
||||
runner._invoke_model.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_builds_prompt_from_ledger_with_session_offsets(mock_context):
|
||||
"""Turn 2 must see: the static system prefix, the committed turn 1 (its
|
||||
@@ -647,9 +740,11 @@ async def test_flash_stops_repeated_no_tool_responses(mock_context, monkeypatch,
|
||||
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)]
|
||||
monkeypatch.setattr(settings, "ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS", 3)
|
||||
responses = [AIMessage(content="I will click now.", tool_calls=[]) for _ in range(attempts + 3)]
|
||||
with (
|
||||
patch("artemis.controllers.unified_controller.get_driver"),
|
||||
patch("artemis.agents.flash.runner.get_llm", return_value=Mock()) as get_fallback,
|
||||
patch(
|
||||
"artemis.agents.flash.runner.capture_screenshot_and_parse_ui",
|
||||
AsyncMock(return_value=_INITIAL_OBSERVATION),
|
||||
@@ -659,11 +754,55 @@ async def test_flash_stops_repeated_no_tool_responses(mock_context, monkeypatch,
|
||||
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
|
||||
assert f"连续 {attempts} 次" in result["explanation"]
|
||||
assert "兜底模型连续 3 次" in result["explanation"]
|
||||
assert runner._invoke_model.await_count == attempts + 3
|
||||
get_fallback.assert_called_once_with(mock_context, name="operator", use_fallback=True)
|
||||
runner.executor.execute.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_flash_uses_fallback_for_one_tool_turn_then_primary(mock_context):
|
||||
no_call = AIMessage(content="I will click now.")
|
||||
observe = AIMessage(
|
||||
content="Check the target.",
|
||||
tool_calls=[
|
||||
{
|
||||
"name": "ask_explorer",
|
||||
"args": {"question": "Find Settings"},
|
||||
"id": "fallback-observe",
|
||||
}
|
||||
],
|
||||
)
|
||||
click = AIMessage(
|
||||
content="Tap Settings.",
|
||||
tool_calls=[{"name": "click", "args": {"target": 1}, "id": "fallback-click"}],
|
||||
)
|
||||
fallback = Mock()
|
||||
responses = [no_call, no_call, no_call, observe, click, _report()]
|
||||
with (
|
||||
patch("artemis.controllers.unified_controller.get_driver"),
|
||||
patch("artemis.agents.flash.runner.get_llm", return_value=fallback),
|
||||
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"))
|
||||
|
||||
primary = runner.calls[0]["llm"]
|
||||
assert [call["llm"] for call in runner.calls] == [
|
||||
primary,
|
||||
primary,
|
||||
primary,
|
||||
fallback,
|
||||
fallback,
|
||||
primary,
|
||||
]
|
||||
assert result["status"] == "completed"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _TurnRecord.result: the turn's execution result in the validator-report shape
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -107,23 +107,56 @@ async def test_invalid_target_can_be_corrected_before_limit(operator, monkeypatc
|
||||
@pytest.mark.parametrize("limit", [1, 3])
|
||||
async def test_empty_responses_fail_at_configured_limit(operator, monkeypatch, limit):
|
||||
monkeypatch.setattr(settings, "ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS", limit)
|
||||
monkeypatch.setattr(settings, "ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS", 3)
|
||||
fallback = Mock()
|
||||
get_fallback = Mock(return_value=fallback)
|
||||
monkeypatch.setattr("artemis.agents.operator.operator.get_llm", get_fallback)
|
||||
invoke = AsyncMock(return_value=AIMessage(content="I will click now."))
|
||||
monkeypatch.setattr("artemis.agents.operator.operator.acomplete", invoke)
|
||||
node, state = operator
|
||||
with pytest.raises(OperatorNoProgressError, match="未提供可执行的工具调用"):
|
||||
await node._invoke_llm_loop(Mock(), [], [], [], state)
|
||||
assert invoke.await_count == limit
|
||||
assert invoke.await_count == limit + 3
|
||||
get_fallback.assert_called_once_with(ctx=node.ctx, name="operator", use_fallback=True)
|
||||
assert invoke.call_args_list[-1].args[0] is fallback.bind_tools.return_value
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_operator_fallback_is_scoped_to_current_step(operator, monkeypatch):
|
||||
node, state = operator
|
||||
primary = Mock()
|
||||
fallback = Mock()
|
||||
get_fallback = Mock(return_value=fallback)
|
||||
monkeypatch.setattr("artemis.agents.operator.operator.get_llm", get_fallback)
|
||||
invoke = AsyncMock(side_effect=[AIMessage(content="no tool")] * 3 + [click(), click()])
|
||||
monkeypatch.setattr("artemis.agents.operator.operator.acomplete", invoke)
|
||||
|
||||
first = await node._invoke_llm_loop(primary, [], [], [], state)
|
||||
second = await node._invoke_llm_loop(primary, [], [], [], state)
|
||||
|
||||
assert first[0] and second[0]
|
||||
assert get_fallback.call_count == 1
|
||||
assert [call.args[0] for call in invoke.call_args_list] == [
|
||||
primary.bind_tools.return_value,
|
||||
primary.bind_tools.return_value,
|
||||
primary.bind_tools.return_value,
|
||||
fallback.bind_tools.return_value,
|
||||
primary.bind_tools.return_value,
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_calls_and_prose_share_bounded_recovery(operator, monkeypatch):
|
||||
invoke = AsyncMock(side_effect=[click("missing"), AIMessage(content="Retry."), click("14")])
|
||||
invoke = AsyncMock(
|
||||
side_effect=[click("missing"), AIMessage(content="Retry."), click("14")] + [click("14")] * 3
|
||||
)
|
||||
monkeypatch.setattr("artemis.agents.operator.operator.get_llm", Mock(return_value=Mock()))
|
||||
monkeypatch.setattr("artemis.agents.operator.operator.acomplete", invoke)
|
||||
node, state = operator
|
||||
messages = []
|
||||
with pytest.raises(OperatorNoProgressError, match="校验仍失败"):
|
||||
await node._invoke_llm_loop(Mock(), messages, [], [], state)
|
||||
assert invoke.await_count == 3
|
||||
assert invoke.await_count == 6
|
||||
assert any(isinstance(m, ToolMessage) and m.status == "error" for m in messages)
|
||||
assert not any(isinstance(m, ToolMessage) and m.content == "Action Recorded" for m in messages)
|
||||
|
||||
|
||||
@@ -33,6 +33,17 @@ class DummyState:
|
||||
self.operator_replan_reason = operator_replan_reason
|
||||
|
||||
|
||||
import base64
|
||||
from io import BytesIO
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def _valid_screen_base64():
|
||||
buffer = BytesIO()
|
||||
Image.new("RGB", (64, 64), "white").save(buffer, format="PNG")
|
||||
return base64.b64encode(buffer.getvalue()).decode("ascii")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_context():
|
||||
ctx = Mock(spec=ArtemisContext)
|
||||
@@ -41,7 +52,7 @@ def mock_context():
|
||||
ctx.adb_client = Mock()
|
||||
ctx.ui_adb_client = Mock()
|
||||
mock_screen_data = Mock()
|
||||
mock_screen_data.base64 = "YmFzZTY0"
|
||||
mock_screen_data.base64 = _valid_screen_base64()
|
||||
mock_screen_data.elements = []
|
||||
mock_screen_data.width = 1080
|
||||
mock_screen_data.height = 2400
|
||||
|
||||
@@ -32,6 +32,14 @@ async def test_config_requires_explicit_persistence_and_excludes_secrets(monkeyp
|
||||
"reasoning_effort": "high",
|
||||
}
|
||||
|
||||
await tools.mobile_config(
|
||||
changes={"rate_limit_retry_seconds": 90, "tool_fallback_model": "gpt-6-luna"},
|
||||
persist_defaults=True,
|
||||
)
|
||||
payload = request.call_args.kwargs["json"]
|
||||
assert payload["rate_limit_retry_seconds"] == 90
|
||||
assert payload["tool_fallback_model"] == "gpt-6-luna"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preview_is_read_only_and_create_is_draft(monkeypatch, tmp_path):
|
||||
|
||||
@@ -93,6 +93,7 @@ def test_ensure_daemon_running_standalone_override(monkeypatch):
|
||||
def test_ensure_daemon_running_auto_spawns():
|
||||
with (
|
||||
patch("artemis.runtime.daemon_client.is_daemon_running", side_effect=[False, True]),
|
||||
patch("artemis.runtime.daemon_client.is_port_in_use", return_value=False),
|
||||
patch("artemis.runtime.daemon_client.spawn_daemon") as mock_spawn,
|
||||
):
|
||||
ok, base_url = ensure_daemon_running(timeout=1.0, wait_ready=True)
|
||||
@@ -101,6 +102,42 @@ def test_ensure_daemon_running_auto_spawns():
|
||||
mock_spawn.assert_called_once()
|
||||
|
||||
|
||||
def test_busy_live_daemon_is_reused_without_spawning():
|
||||
with (
|
||||
patch("artemis.runtime.daemon_client.is_daemon_running", return_value=False),
|
||||
patch("artemis.runtime.daemon_client.is_port_in_use", return_value=True),
|
||||
patch(
|
||||
"artemis.runtime.daemon_client.read_server_info",
|
||||
return_value={"host": "127.0.0.1", "port": 8000, "pid": 12345},
|
||||
),
|
||||
patch("artemis.runtime.daemon_client.pid_is_alive", return_value=True),
|
||||
patch("artemis.runtime.daemon_client.spawn_daemon") as spawn,
|
||||
):
|
||||
assert ensure_daemon_running() == (True, "http://127.0.0.1:8000")
|
||||
spawn.assert_not_called()
|
||||
|
||||
|
||||
def test_unknown_port_owner_is_not_overwritten():
|
||||
with (
|
||||
patch("artemis.runtime.daemon_client.is_daemon_running", return_value=False),
|
||||
patch("artemis.runtime.daemon_client.is_port_in_use", return_value=True),
|
||||
patch("artemis.runtime.daemon_client.read_server_info", return_value=None),
|
||||
patch("artemis.runtime.daemon_client.spawn_daemon") as spawn,
|
||||
):
|
||||
assert ensure_daemon_running() == (False, None)
|
||||
spawn.assert_not_called()
|
||||
|
||||
|
||||
def test_liveness_probe_uses_lightweight_endpoint_and_configured_timeout(monkeypatch):
|
||||
from artemis.config.settings import settings
|
||||
|
||||
monkeypatch.setattr(settings, "ARTEMIS_DAEMON_PROBE_TIMEOUT_SECONDS", 7.0)
|
||||
with patch("urllib.request.urlopen", return_value=_daemon_ok_response(b"{}")) as probe:
|
||||
assert is_daemon_running()
|
||||
assert probe.call_args.args[0].full_url.endswith("/api/system/server-status")
|
||||
assert probe.call_args.kwargs["timeout"] == 7.0
|
||||
|
||||
|
||||
def test_submit_task_to_daemon():
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status = 200
|
||||
|
||||
@@ -225,6 +225,20 @@ async def test_api_server_status():
|
||||
assert "current_pid" in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_server_status_does_not_scan_ports():
|
||||
with patch(
|
||||
"artemis.runtime.server_lifecycle.get_server_status",
|
||||
side_effect=AssertionError("port scan"),
|
||||
):
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app), base_url="http://localhost"
|
||||
) as client:
|
||||
response = await client.get("/api/system/server-status")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["running"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_restart_endpoint():
|
||||
"""Verify POST /api/system/restart accepts request and schedules restart."""
|
||||
|
||||
+37
-291
@@ -14,6 +14,8 @@
|
||||
|
||||
"""Unit tests for ARTEMIS Unified CLI application."""
|
||||
|
||||
import pytest
|
||||
|
||||
from typer.testing import CliRunner
|
||||
from artemis.interfaces.cli.main import app
|
||||
|
||||
@@ -169,56 +171,41 @@ def test_cli_mcp_help():
|
||||
assert "--generate-config" in result.output
|
||||
|
||||
|
||||
def test_cli_mcp_generate_config():
|
||||
"""Verify 'artemis mcp --generate-config cursor' produces valid configuration."""
|
||||
result = runner.invoke(app, ["mcp", "--generate-config", "cursor"])
|
||||
assert result.exit_code == 0
|
||||
assert "mcpServers" in result.output
|
||||
assert "mcp_server" in result.output
|
||||
|
||||
|
||||
def test_cli_mcp_generate_config_antigravity():
|
||||
"""Verify current Antigravity uses only documented, load-safe fields."""
|
||||
from artemis.interfaces.cli.commands.mcp import _get_config_snippet
|
||||
|
||||
result = runner.invoke(app, ["mcp", "--generate-config", "antigravity"])
|
||||
assert result.exit_code == 0
|
||||
assert "mcpServers" in result.output
|
||||
server_config = _get_config_snippet("antigravity", "python", "/project")["mcpServers"][
|
||||
"artemis"
|
||||
]
|
||||
assert server_config["disabledTools"] == []
|
||||
assert "tools" not in server_config
|
||||
|
||||
legacy_config = _get_config_snippet("jetski", "python", "/project")["mcpServers"]["artemis"]
|
||||
assert set(legacy_config["tools"]) == {
|
||||
"mobile_run_task",
|
||||
"mobile_manage_task",
|
||||
"mobile_get_device_state",
|
||||
"mobile_inspect_trace",
|
||||
"mobile_diagnose",
|
||||
"mobile_workspace",
|
||||
"mobile_config",
|
||||
"mobile_task_file",
|
||||
"mobile_batch",
|
||||
"mobile_search_history",
|
||||
"mobile_export_report",
|
||||
}
|
||||
assert all(tool["eager"] is True for tool in legacy_config["tools"].values())
|
||||
|
||||
|
||||
def test_cli_mcp_generate_config_all():
|
||||
"""Verify 'artemis mcp --generate-config all' includes every supported client."""
|
||||
result = runner.invoke(app, ["mcp", "--generate-config", "all"])
|
||||
assert result.exit_code == 0
|
||||
assert "antigravity" in result.output
|
||||
assert "cursor" in result.output
|
||||
assert "windsurf" in result.output
|
||||
assert "claude" in result.output
|
||||
assert "vscode" in result.output
|
||||
assert "cline" in result.output
|
||||
assert "roo" in result.output
|
||||
assert "codex" in result.output
|
||||
@pytest.mark.parametrize("option", ["--install", "--generate-config"])
|
||||
@pytest.mark.parametrize(
|
||||
"client",
|
||||
[
|
||||
"cursor",
|
||||
"vscode",
|
||||
"claude",
|
||||
"claude_code",
|
||||
"claude_desktop",
|
||||
"antigravity",
|
||||
"jetski",
|
||||
"windsurf",
|
||||
"cline",
|
||||
"roo",
|
||||
"roo_code",
|
||||
"openclaw",
|
||||
"all",
|
||||
],
|
||||
)
|
||||
def test_cli_mcp_rejects_other_clients_without_modifying_files(
|
||||
tmp_path, monkeypatch, option, client
|
||||
):
|
||||
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
|
||||
monkeypatch.setenv("CODEX_HOME", str(tmp_path / ".codex"))
|
||||
monkeypatch.setenv("APPDATA", str(tmp_path / "AppData/Roaming"))
|
||||
monkeypatch.setattr("mcp_server.utils.env_utils.get_project_root", lambda: str(tmp_path))
|
||||
sentinel = tmp_path / ".cursor/mcp.json"
|
||||
sentinel.parent.mkdir()
|
||||
sentinel.write_bytes(b"{invalid json with user data}")
|
||||
before = {p.relative_to(tmp_path): p.read_bytes() for p in tmp_path.rglob("*") if p.is_file()}
|
||||
result = runner.invoke(app, ["mcp", option, client])
|
||||
assert result.exit_code == 1
|
||||
assert "Codex" in result.output
|
||||
after = {p.relative_to(tmp_path): p.read_bytes() for p in tmp_path.rglob("*") if p.is_file()}
|
||||
assert after == before
|
||||
|
||||
|
||||
def test_cli_mcp_generate_config_codex():
|
||||
@@ -251,165 +238,6 @@ def test_cli_mcp_generate_config_codex():
|
||||
]
|
||||
|
||||
|
||||
def test_cli_mcp_install_antigravity(tmp_path, monkeypatch):
|
||||
"""Verify 'artemis mcp --install antigravity' writes configuration and global rules into target files."""
|
||||
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
|
||||
monkeypatch.setattr("mcp_server.utils.env_utils.get_project_root", lambda: str(tmp_path))
|
||||
result = runner.invoke(app, ["mcp", "--install", "antigravity"])
|
||||
assert result.exit_code == 0
|
||||
assert "Successfully installed ARTEMIS MCP server configuration & rules" in result.output
|
||||
jetski_file = tmp_path / ".gemini" / "jetski" / "mcp_config.json"
|
||||
assert jetski_file.exists()
|
||||
import json
|
||||
|
||||
data = json.loads(jetski_file.read_text())
|
||||
assert "artemis" in data["mcpServers"]
|
||||
assert "mobile_run_task" in data["mcpServers"]["artemis"]["tools"]
|
||||
assert "PYTHONPATH" in data["mcpServers"]["artemis"]["env"]
|
||||
|
||||
current_file = tmp_path / ".gemini" / "config" / "mcp_config.json"
|
||||
current_data = json.loads(current_file.read_text())
|
||||
assert current_data["mcpServers"]["artemis"]["disabledTools"] == []
|
||||
assert "tools" not in current_data["mcpServers"]["artemis"]
|
||||
|
||||
# Verify global rule file installed
|
||||
gemini_md = tmp_path / ".gemini" / "GEMINI.md"
|
||||
assert gemini_md.exists()
|
||||
assert "Mobile Testing Mindset" in gemini_md.read_text(encoding="utf-8")
|
||||
rule_file = tmp_path / ".gemini" / "rules" / "artemis.md"
|
||||
assert rule_file.exists()
|
||||
assert "Mobile Testing Mindset" in rule_file.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_cli_mcp_install_all(tmp_path, monkeypatch):
|
||||
"""Verify 'artemis mcp --install all' installs configs and global rules to all supported IDE locations."""
|
||||
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
|
||||
monkeypatch.setenv("APPDATA", str(tmp_path / "AppData" / "Roaming"))
|
||||
monkeypatch.setattr("mcp_server.utils.env_utils.get_project_root", lambda: str(tmp_path))
|
||||
result = runner.invoke(app, ["mcp", "--install", "all"])
|
||||
assert result.exit_code == 0
|
||||
assert "Successfully installed ARTEMIS MCP server configuration & rules" in result.output
|
||||
assert (tmp_path / ".cursor" / "mcp.json").exists()
|
||||
assert (tmp_path / ".codeium" / "windsurf" / "mcp_config.json").exists()
|
||||
assert (tmp_path / ".openclaw" / "openclaw.json").exists()
|
||||
assert (tmp_path / ".claude.json").exists()
|
||||
assert (tmp_path / ".codex" / "config.toml").exists()
|
||||
|
||||
import json
|
||||
|
||||
cursor_data = json.loads((tmp_path / ".cursor" / "mcp.json").read_text())
|
||||
assert cursor_data["mcpServers"]["artemis"]["command"]
|
||||
|
||||
windsurf_data = json.loads((tmp_path / ".codeium" / "windsurf" / "mcp_config.json").read_text())
|
||||
assert windsurf_data["mcpServers"]["artemis"]["command"]
|
||||
|
||||
claude_data = json.loads((tmp_path / ".claude.json").read_text())
|
||||
assert claude_data["mcpServers"]["artemis"]["type"] == "stdio"
|
||||
|
||||
from artemis.interfaces.cli.commands.mcp import _get_vscode_user_dir
|
||||
|
||||
vscode_data = json.loads((_get_vscode_user_dir() / "mcp.json").read_text())
|
||||
assert vscode_data["servers"]["artemis"]["type"] == "stdio"
|
||||
assert "mcpServers" not in vscode_data
|
||||
|
||||
copilot_data = json.loads((tmp_path / ".copilot" / "mcp-config.json").read_text())
|
||||
assert copilot_data["servers"]["artemis"]["type"] == "stdio"
|
||||
|
||||
openclaw_data = json.loads((tmp_path / ".openclaw" / "openclaw.json").read_text())
|
||||
assert openclaw_data["mcp"]["servers"]["artemis"]["enabled"] is True
|
||||
|
||||
cline_data = json.loads(
|
||||
(tmp_path / ".cline" / "data" / "settings" / "cline_mcp_settings.json").read_text()
|
||||
)
|
||||
assert cline_data["mcpServers"]["artemis"]["disabled"] is False
|
||||
|
||||
roo_data = json.loads((tmp_path / ".roo" / "mcp.json").read_text())
|
||||
assert roo_data["mcpServers"]["artemis"]["disabled"] is False
|
||||
|
||||
# Verify global rule files installed across IDEs
|
||||
assert (tmp_path / ".gemini" / "GEMINI.md").exists()
|
||||
assert (tmp_path / ".gemini" / "rules" / "artemis.md").exists()
|
||||
assert (tmp_path / ".cursorrules").exists()
|
||||
assert (tmp_path / ".cursor" / "rules" / "artemis.mdc").exists()
|
||||
# Claude Code loads both CLAUDE.md and rules/*.md, so rules are installed
|
||||
# only as the standalone rule file to avoid duplicated context.
|
||||
assert not (tmp_path / ".claude" / "CLAUDE.md").exists()
|
||||
assert (tmp_path / ".claude" / "rules" / "artemis.md").exists()
|
||||
assert (tmp_path / ".codeium" / "windsurf" / "memories" / "global_rules.md").exists()
|
||||
assert (tmp_path / ".codeium" / "windsurf" / "rules" / "artemis.md").exists()
|
||||
assert (tmp_path / ".vscode" / "rules" / "artemis.md").exists()
|
||||
assert (tmp_path / ".clinerules").exists()
|
||||
assert (tmp_path / ".cline" / "rules" / "artemis.md").exists()
|
||||
assert (tmp_path / ".roorules").exists()
|
||||
assert (tmp_path / ".roo" / "rules" / "artemis.md").exists()
|
||||
assert (tmp_path / ".openclaw" / "OPENCLAW.md").exists()
|
||||
assert (tmp_path / ".openclaw" / "rules" / "artemis.md").exists()
|
||||
assert (tmp_path / ".codex" / "AGENTS.md").exists()
|
||||
|
||||
|
||||
def test_cli_mcp_install_claude_migrates_legacy_claude_md(tmp_path, monkeypatch):
|
||||
"""Verify the claude target removes a previously injected CLAUDE.md block while preserving user content."""
|
||||
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
|
||||
monkeypatch.setenv("APPDATA", str(tmp_path / "AppData" / "Roaming"))
|
||||
monkeypatch.setattr("mcp_server.utils.env_utils.get_project_root", lambda: str(tmp_path))
|
||||
|
||||
claude_dir = tmp_path / ".claude"
|
||||
claude_dir.mkdir(parents=True)
|
||||
claude_md = claude_dir / "CLAUDE.md"
|
||||
claude_md.write_text(
|
||||
"# My own instructions\n\n"
|
||||
"<!-- BEGIN ARTEMIS MOBILE TESTING RULES -->\nold injected rules\n"
|
||||
"<!-- END ARTEMIS MOBILE TESTING RULES -->\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["mcp", "--install", "claude"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
remaining = claude_md.read_text(encoding="utf-8")
|
||||
assert "My own instructions" in remaining
|
||||
assert "ARTEMIS MOBILE TESTING RULES" not in remaining
|
||||
rule_file = claude_dir / "rules" / "artemis.md"
|
||||
assert rule_file.exists()
|
||||
assert "Mobile Testing Mindset" in rule_file.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_cli_mcp_install_claude_deletes_block_only_claude_md(tmp_path, monkeypatch):
|
||||
"""Verify a CLAUDE.md consisting solely of the injected block is deleted outright."""
|
||||
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
|
||||
monkeypatch.setenv("APPDATA", str(tmp_path / "AppData" / "Roaming"))
|
||||
monkeypatch.setattr("mcp_server.utils.env_utils.get_project_root", lambda: str(tmp_path))
|
||||
|
||||
claude_dir = tmp_path / ".claude"
|
||||
claude_dir.mkdir(parents=True)
|
||||
claude_md = claude_dir / "CLAUDE.md"
|
||||
claude_md.write_text(
|
||||
"<!-- BEGIN ARTEMIS MOBILE TESTING RULES -->\nold injected rules\n"
|
||||
"<!-- END ARTEMIS MOBILE TESTING RULES -->\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["mcp", "--install", "claude"])
|
||||
assert result.exit_code == 0
|
||||
assert not claude_md.exists()
|
||||
assert (claude_dir / "rules" / "artemis.md").exists()
|
||||
|
||||
|
||||
def test_cli_mcp_install_claude_refuses_unparseable_claude_json(tmp_path, monkeypatch):
|
||||
"""Verify an unparseable ~/.claude.json is left untouched instead of being rewritten."""
|
||||
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
|
||||
monkeypatch.setenv("APPDATA", str(tmp_path / "AppData" / "Roaming"))
|
||||
monkeypatch.setattr("mcp_server.utils.env_utils.get_project_root", lambda: str(tmp_path))
|
||||
|
||||
corrupt = "{ this is not json at all"
|
||||
claude_json = tmp_path / ".claude.json"
|
||||
claude_json.write_text(corrupt, encoding="utf-8")
|
||||
|
||||
result = runner.invoke(app, ["mcp", "--install", "claude"])
|
||||
assert result.exit_code == 0
|
||||
assert claude_json.read_text(encoding="utf-8") == corrupt
|
||||
|
||||
|
||||
def test_cli_mcp_install_codex_preserves_config_and_is_idempotent(tmp_path, monkeypatch):
|
||||
"""Verify Codex TOML merging preserves other servers and updates only Artemis."""
|
||||
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
|
||||
@@ -461,88 +289,6 @@ def test_cli_mcp_install_codex_preserves_config_and_is_idempotent(tmp_path, monk
|
||||
assert agents_text.count("<!-- BEGIN ARTEMIS MOBILE TESTING RULES -->") == 1
|
||||
|
||||
|
||||
def test_cli_mcp_install_jsonc_and_backup(tmp_path, monkeypatch):
|
||||
"""Verify merging configuration preserves valid JSONC and creates .bak for unparseable JSON."""
|
||||
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
|
||||
monkeypatch.setattr("mcp_server.utils.env_utils.get_project_root", lambda: str(tmp_path))
|
||||
|
||||
cursor_dir = tmp_path / ".cursor"
|
||||
cursor_dir.mkdir(parents=True, exist_ok=True)
|
||||
mcp_json = cursor_dir / "mcp.json"
|
||||
|
||||
# 1. Valid JSONC with comments and trailing comma
|
||||
mcp_json.write_text(
|
||||
'{\n // My existing server\n "mcpServers": {\n "test": {"command": "echo"},\n }\n}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
result = runner.invoke(app, ["mcp", "--install", "cursor"])
|
||||
assert result.exit_code == 0
|
||||
import json
|
||||
|
||||
data = json.loads(mcp_json.read_text(encoding="utf-8"))
|
||||
assert "test" in data["mcpServers"]
|
||||
assert "artemis" in data["mcpServers"]
|
||||
|
||||
# 2. Corrupt / unparseable file triggers backup
|
||||
mcp_json.write_text("INVALID JSON DATA {{{{", encoding="utf-8")
|
||||
result2 = runner.invoke(app, ["mcp", "--install", "cursor"])
|
||||
assert result2.exit_code == 0
|
||||
backup_file = cursor_dir / "mcp.json.bak"
|
||||
assert backup_file.exists()
|
||||
assert "INVALID JSON DATA" in backup_file.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_cli_mcp_install_refuses_rewrite_when_backup_fails(tmp_path, monkeypatch):
|
||||
"""Backup failure for an unparseable config must abort the rewrite instead of destroying it."""
|
||||
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
|
||||
monkeypatch.setattr("mcp_server.utils.env_utils.get_project_root", lambda: str(tmp_path))
|
||||
|
||||
cursor_dir = tmp_path / ".cursor"
|
||||
cursor_dir.mkdir(parents=True, exist_ok=True)
|
||||
mcp_json = cursor_dir / "mcp.json"
|
||||
original = "INVALID JSON DATA {{{{"
|
||||
mcp_json.write_text(original, encoding="utf-8")
|
||||
|
||||
import pathlib
|
||||
|
||||
real_write_text = pathlib.Path.write_text
|
||||
|
||||
def failing_bak_write(self, *args, **kwargs):
|
||||
if str(self).endswith(".bak"):
|
||||
raise OSError("disk full")
|
||||
return real_write_text(self, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr("pathlib.Path.write_text", failing_bak_write)
|
||||
|
||||
runner.invoke(app, ["mcp", "--install", "cursor"])
|
||||
|
||||
assert mcp_json.read_text(encoding="utf-8") == original
|
||||
assert not (cursor_dir / "mcp.json.bak").exists()
|
||||
|
||||
|
||||
def test_cli_mcp_install_openclaw_migrates_legacy_plugin_shape(tmp_path, monkeypatch):
|
||||
"""Verify reinstalling OpenClaw replaces the obsolete plugin wrapper with mcp.servers."""
|
||||
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
|
||||
monkeypatch.setattr("mcp_server.utils.env_utils.get_project_root", lambda: str(tmp_path))
|
||||
config_path = tmp_path / ".openclaw" / "openclaw.json"
|
||||
config_path.parent.mkdir(parents=True)
|
||||
config_path.write_text(
|
||||
'{"plugins":{"artemis_mcp":{"enabled":true},"keep":{"enabled":true}}}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["mcp", "--install", "openclaw"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
import json
|
||||
|
||||
data = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
assert "artemis_mcp" not in data["plugins"]
|
||||
assert data["plugins"]["keep"]["enabled"] is True
|
||||
assert data["mcp"]["servers"]["artemis"]["enabled"] is True
|
||||
assert data["mcp"]["servers"]["artemis"]["command"]
|
||||
|
||||
|
||||
def test_cli_restart_help():
|
||||
"""Verify 'artemis restart --help' displays lifecycle options."""
|
||||
result = runner.invoke(app, ["restart", "--help"])
|
||||
|
||||
@@ -14,15 +14,21 @@
|
||||
|
||||
"""Unit tests for Unified Base Device Driver and implementations."""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import threading
|
||||
from io import BytesIO
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from artemis.drivers.base import KeyCode, ScreenData, SwipeDirection
|
||||
from artemis.drivers.mock.mock_driver import MockDeviceDriver
|
||||
from artemis.drivers.android.adb_driver import AndroidAdbDriver
|
||||
from artemis.config.agent import ScreenshotConfig
|
||||
from artemis.utils.screenshot_limit import limit_screenshot_long_edge
|
||||
|
||||
|
||||
_ONE_PIXEL_PNG = base64.b64encode(
|
||||
@@ -32,6 +38,55 @@ _ONE_PIXEL_PNG = base64.b64encode(
|
||||
).decode("ascii")
|
||||
|
||||
|
||||
def _valid_png():
|
||||
buffer = BytesIO()
|
||||
Image.new("RGB", (64, 64), "white").save(buffer, format="PNG")
|
||||
return base64.b64encode(buffer.getvalue()).decode("ascii")
|
||||
|
||||
|
||||
_VALID_PNG = _valid_png()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_android_driver_rejects_placeholder_without_returning_stale_screen():
|
||||
client = MagicMock()
|
||||
client.get_screen_data.return_value = SimpleNamespace(base64=_ONE_PIXEL_PNG)
|
||||
driver = AndroidAdbDriver("test-device", MagicMock(), ui_adb_client=client)
|
||||
with pytest.raises(RuntimeError, match="占位图"):
|
||||
await driver.get_screen_data(skip_settling=True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_screen_capture_timeout_does_not_start_overlapping_sdk_calls(monkeypatch):
|
||||
released = threading.Event()
|
||||
client = MagicMock()
|
||||
|
||||
def blocked_capture():
|
||||
released.wait(2)
|
||||
return SimpleNamespace(base64=_VALID_PNG, width=64, height=64, elements=[])
|
||||
|
||||
client.get_screen_data.side_effect = blocked_capture
|
||||
driver = AndroidAdbDriver("test-device", MagicMock(), ui_adb_client=client)
|
||||
monkeypatch.setattr(
|
||||
"artemis.drivers.android.adb_driver.settings.ARTEMIS_OBSERVATION_TIMEOUT_SECONDS", 0.02
|
||||
)
|
||||
try:
|
||||
for _ in range(3):
|
||||
with pytest.raises(TimeoutError, match="观测超时"):
|
||||
await driver.get_screen_data(skip_settling=True)
|
||||
assert client.get_screen_data.call_count == 1
|
||||
finally:
|
||||
released.set()
|
||||
await asyncio.wait_for(driver._screen_capture_task, 2)
|
||||
|
||||
|
||||
def test_screenshot_limit_keeps_small_and_invalid_images_unchanged():
|
||||
small_image = base64.b64decode(_ONE_PIXEL_PNG)
|
||||
assert limit_screenshot_long_edge(small_image, 2048) is small_image
|
||||
invalid_image = b"not an image"
|
||||
assert limit_screenshot_long_edge(invalid_image, 2048) is invalid_image
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mock_driver_actions():
|
||||
"""Verify MockDeviceDriver records and performs all core actions."""
|
||||
@@ -89,16 +144,16 @@ async def test_android_driver_with_mock_adb():
|
||||
|
||||
# Test tap
|
||||
await driver.tap(540, 1200)
|
||||
mock_adb_device.shell.assert_called_with("input tap 540 1200")
|
||||
mock_adb_device.shell.assert_called_with("input tap 540 1200", timeout=10.0)
|
||||
|
||||
# Test multi-tap
|
||||
await driver.tap(540, 1200, times=3, delay_ms=100)
|
||||
expected_multi = "input tap 540 1200 && sleep 0.100 && input tap 540 1200 && sleep 0.100 && input tap 540 1200"
|
||||
mock_adb_device.shell.assert_called_with(expected_multi)
|
||||
mock_adb_device.shell.assert_called_with(expected_multi, timeout=10.0)
|
||||
|
||||
# Test press key
|
||||
await driver.press_key(KeyCode.BACK)
|
||||
mock_adb_device.shell.assert_called_with("input keyevent 4")
|
||||
mock_adb_device.shell.assert_called_with("input keyevent 4", timeout=10.0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -121,7 +176,7 @@ async def test_android_driver_preserves_elements_from_combined_screen_data():
|
||||
},
|
||||
]
|
||||
mock_ui_client.get_screen_data.return_value = SimpleNamespace(
|
||||
base64=_ONE_PIXEL_PNG,
|
||||
base64=_VALID_PNG,
|
||||
hierarchy_xml="<hierarchy />",
|
||||
elements=expected_elements,
|
||||
width=1080,
|
||||
@@ -141,13 +196,43 @@ async def test_android_driver_preserves_elements_from_combined_screen_data():
|
||||
mock_ui_client.get_hierarchy.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("enabled", [True, False])
|
||||
async def test_android_screenshot_limit_preserves_native_coordinates(enabled):
|
||||
image = Image.new("RGB", (1200, 2600), "white")
|
||||
buffer = BytesIO()
|
||||
image.save(buffer, format="JPEG")
|
||||
original = buffer.getvalue()
|
||||
elements = [{"text": "目标", "bounds": "[100,200][300,400]"}]
|
||||
ui_client = MagicMock()
|
||||
ui_client.get_screen_data.return_value = SimpleNamespace(
|
||||
base64=base64.b64encode(original).decode("ascii"),
|
||||
hierarchy_xml="<hierarchy />",
|
||||
elements=elements,
|
||||
width=1200,
|
||||
height=2600,
|
||||
)
|
||||
driver = AndroidAdbDriver("emulator-5554", MagicMock(), ui_adb_client=ui_client)
|
||||
driver._screenshot_config = ScreenshotConfig(enabled=enabled, max_long_edge=2048)
|
||||
|
||||
screen = await driver.get_screen_data(skip_settling=True)
|
||||
|
||||
with Image.open(BytesIO(screen.screenshot_bytes)) as result:
|
||||
assert result.size == ((945, 2048) if enabled else (1200, 2600))
|
||||
assert screen.width == 1200 and screen.height == 2600
|
||||
assert screen.ui_elements == elements
|
||||
assert screen.screenshot_base64 == base64.b64encode(screen.screenshot_bytes).decode("ascii")
|
||||
if not enabled:
|
||||
assert screen.screenshot_bytes == original
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_android_driver_parses_xml_hierarchy_fallback():
|
||||
"""XML-only clients remain supported without discarding their hierarchy."""
|
||||
mock_adb_client = MagicMock()
|
||||
mock_ui_client = MagicMock()
|
||||
mock_ui_client.get_screen_data.return_value = SimpleNamespace(
|
||||
base64=_ONE_PIXEL_PNG,
|
||||
base64=_VALID_PNG,
|
||||
width=1080,
|
||||
height=2400,
|
||||
)
|
||||
@@ -175,7 +260,7 @@ async def test_find_element_prefers_fresh_bounds_over_stale_center():
|
||||
driver.get_screen_data = AsyncMock(
|
||||
return_value=ScreenData(
|
||||
screenshot_bytes=base64.b64decode(_ONE_PIXEL_PNG),
|
||||
screenshot_base64=_ONE_PIXEL_PNG,
|
||||
screenshot_base64=_VALID_PNG,
|
||||
ui_elements=[
|
||||
{
|
||||
"resource_id": "app:id/digit_4",
|
||||
|
||||
@@ -120,33 +120,7 @@ command = "leave-alone"
|
||||
assert path.with_name(path.name + ".artemis-update.bak").read_text(encoding="utf-8") == original
|
||||
|
||||
|
||||
def test_json_migrates_existing_standard_mcp_to_current_checkout(tmp_path):
|
||||
path = tmp_path / "mcp.json"
|
||||
root = tmp_path / "project"
|
||||
config = {
|
||||
"cwd": str(root),
|
||||
"command": "old",
|
||||
"args": ["-m", "mcp_server"],
|
||||
"disabled": True,
|
||||
"env": {"CUSTOM": "preserved"},
|
||||
"autoApprove": [],
|
||||
}
|
||||
path.write_text(json.dumps({"mcpServers": {"artemis": config, "other": {"command": "keep"}}}))
|
||||
moved_root = tmp_path / "new release"
|
||||
assert update.refresh_json(path, ("mcpServers",), moved_root, "new", ("mobile_batch",))
|
||||
result = json.loads(path.read_text())["mcpServers"]
|
||||
assert result["artemis"]["env"]["CUSTOM"] == "preserved"
|
||||
assert result["artemis"]["env"]["PYTHONPATH"] == str(moved_root)
|
||||
assert result["artemis"]["cwd"] == str(moved_root)
|
||||
assert result["artemis"]["disabled"] is True
|
||||
assert result["other"] == {"command": "keep"}
|
||||
assert not update.refresh_json(path, ("mcpServers",), moved_root, "new", ("mobile_batch",))
|
||||
missing = tmp_path / "not-installed.json"
|
||||
assert not update.refresh_json(missing, ("mcpServers",), root, "new", ())
|
||||
assert not missing.exists()
|
||||
|
||||
|
||||
def test_mcp_sync_installs_detected_clients_and_migrates_codex(tmp_path, monkeypatch):
|
||||
def test_mcp_sync_updates_only_codex_and_preserves_other_clients(tmp_path, monkeypatch):
|
||||
from artemis.interfaces.cli.commands import mcp as mcp_command
|
||||
|
||||
home = tmp_path / "home"
|
||||
@@ -170,8 +144,11 @@ def test_mcp_sync_installs_detected_clients_and_migrates_codex(tmp_path, monkeyp
|
||||
cursor_home = home / ".cursor"
|
||||
cursor_home.mkdir()
|
||||
(cursor_home / "mcp.json").write_text('{"mcpServers":{"other":{"command":"keep"}}}')
|
||||
vscode_home = home / "AppData/Code/User"
|
||||
vscode_home.mkdir(parents=True)
|
||||
vscode_config = vscode_home / "mcp.json"
|
||||
vscode_config.write_bytes(b"{invalid VS Code JSONC}")
|
||||
monkeypatch.setattr("pathlib.Path.home", lambda: home)
|
||||
monkeypatch.setattr(mcp_command, "_get_vscode_user_dir", lambda: home / "vscode-user")
|
||||
monkeypatch.setenv("CODEX_HOME", str(codex_home))
|
||||
monkeypatch.setenv("APPDATA", str(home / "AppData"))
|
||||
root = tmp_path / "new release"
|
||||
@@ -187,26 +164,31 @@ def test_mcp_sync_installs_detected_clients_and_migrates_codex(tmp_path, monkeyp
|
||||
assert entry["env"]["PYTHONPATH"] == str(root)
|
||||
assert entry["enabled_tools"] == list(mcp_command.ARTEMIS_MCP_TOOLS)
|
||||
cursor = json.loads((cursor_home / "mcp.json").read_text(encoding="utf-8"))
|
||||
assert cursor["mcpServers"]["artemis"]["env"]["PYTHONPATH"] == str(root)
|
||||
assert "artemis" not in cursor["mcpServers"]
|
||||
assert cursor["mcpServers"]["other"] == {"command": "keep"}
|
||||
assert vscode_config.read_bytes() == b"{invalid VS Code JSONC}"
|
||||
assert not vscode_config.with_name("mcp.json.artemis-update.bak").exists()
|
||||
first = codex_config.read_text(encoding="utf-8")
|
||||
assert update.refresh_mcp(root) == 0
|
||||
assert codex_config.read_text(encoding="utf-8") == first
|
||||
|
||||
|
||||
def test_mcp_sync_fails_on_invalid_detected_config_without_overwriting(tmp_path, monkeypatch):
|
||||
from artemis.interfaces.cli.commands import mcp as mcp_command
|
||||
|
||||
def test_mcp_sync_fails_on_invalid_codex_config_without_overwriting(tmp_path, monkeypatch, capsys):
|
||||
home = tmp_path / "home"
|
||||
cursor_home = home / ".cursor"
|
||||
cursor_home.mkdir(parents=True)
|
||||
config = cursor_home / "mcp.json"
|
||||
config.write_text("{invalid json", encoding="utf-8")
|
||||
codex_home = home / ".codex"
|
||||
codex_home.mkdir(parents=True)
|
||||
config = codex_home / "config.toml"
|
||||
original = b'model = "keep-secret"\n[mcp_servers.artemis\n'
|
||||
config.write_bytes(original)
|
||||
monkeypatch.setattr("pathlib.Path.home", lambda: home)
|
||||
monkeypatch.setattr(mcp_command, "_get_vscode_user_dir", lambda: home / "vscode-user")
|
||||
monkeypatch.delenv("CODEX_HOME", raising=False)
|
||||
monkeypatch.setenv("CODEX_HOME", str(codex_home))
|
||||
assert update.refresh_mcp(tmp_path / "project") == 1
|
||||
assert config.read_text(encoding="utf-8") == "{invalid json"
|
||||
assert config.read_bytes() == original
|
||||
assert not config.with_name(config.name + ".artemis-update.bak").exists()
|
||||
output = capsys.readouterr().out
|
||||
assert str(config) in output
|
||||
assert "Invalid TOML" in output
|
||||
assert "keep-secret" not in output
|
||||
|
||||
|
||||
def test_mcp_sync_installs_codex_when_client_directory_exists(tmp_path, monkeypatch):
|
||||
@@ -216,7 +198,6 @@ def test_mcp_sync_installs_codex_when_client_directory_exists(tmp_path, monkeypa
|
||||
codex_home = home / ".codex"
|
||||
codex_home.mkdir(parents=True)
|
||||
monkeypatch.setattr("pathlib.Path.home", lambda: home)
|
||||
monkeypatch.setattr(mcp_command, "_get_vscode_user_dir", lambda: home / "vscode-user")
|
||||
monkeypatch.setenv("CODEX_HOME", str(codex_home))
|
||||
root = tmp_path / "project"
|
||||
assert update.refresh_mcp(root) == 0
|
||||
@@ -225,6 +206,59 @@ def test_mcp_sync_installs_codex_when_client_directory_exists(tmp_path, monkeypa
|
||||
assert config["mcp_servers"]["artemis"]["enabled_tools"] == list(mcp_command.ARTEMIS_MCP_TOOLS)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("codex_home_setting", [None, "", "custom"])
|
||||
def test_codex_sync_reports_actual_path_and_ready_state(
|
||||
tmp_path, monkeypatch, capsys, codex_home_setting
|
||||
):
|
||||
home = tmp_path / "home"
|
||||
codex_home = home / ".codex" if codex_home_setting != "custom" else tmp_path / "custom Codex"
|
||||
codex_home.mkdir(parents=True)
|
||||
monkeypatch.setattr("pathlib.Path.home", lambda: home)
|
||||
monkeypatch.setenv("ARTEMIS_LAUNCH_LANG", "zh-CN")
|
||||
if codex_home_setting is None:
|
||||
monkeypatch.delenv("CODEX_HOME", raising=False)
|
||||
else:
|
||||
monkeypatch.setenv("CODEX_HOME", str(codex_home) if codex_home_setting == "custom" else "")
|
||||
root = tmp_path / "project"
|
||||
assert update.refresh_mcp(root) == 0
|
||||
first = capsys.readouterr().out
|
||||
assert str(codex_home / "config.toml") in first
|
||||
assert "已安装" in first
|
||||
assert update.refresh_mcp(root) == 0
|
||||
second = capsys.readouterr().out
|
||||
assert str(codex_home / "config.toml") in second
|
||||
assert "已是最新" in second
|
||||
if codex_home_setting == "custom":
|
||||
assert not (home / ".codex").exists()
|
||||
|
||||
|
||||
def test_codex_sync_reports_missing_directory_without_touching_other_clients(
|
||||
tmp_path, monkeypatch, capsys
|
||||
):
|
||||
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
|
||||
monkeypatch.delenv("CODEX_HOME", raising=False)
|
||||
monkeypatch.setenv("ARTEMIS_LAUNCH_LANG", "zh-CN")
|
||||
assert update.refresh_mcp(tmp_path / "project") == 0
|
||||
output = capsys.readouterr().out
|
||||
assert str(tmp_path / ".codex/config.toml") in output
|
||||
assert "未发现 Codex 配置目录" in output
|
||||
assert "--install codex" in output
|
||||
assert not (tmp_path / ".codex").exists()
|
||||
|
||||
|
||||
def test_custom_codex_command_is_preserved_and_reported(tmp_path, monkeypatch, capsys):
|
||||
codex_home = tmp_path / ".codex"
|
||||
codex_home.mkdir()
|
||||
config = codex_home / "config.toml"
|
||||
original = '[mcp_servers.artemis]\ncommand = "custom"\nargs = ["wrapper"]\n'
|
||||
config.write_text(original, encoding="utf-8")
|
||||
monkeypatch.setenv("CODEX_HOME", str(codex_home))
|
||||
monkeypatch.setenv("ARTEMIS_LAUNCH_LANG", "zh-CN")
|
||||
assert update.refresh_mcp(tmp_path / "project") == 0
|
||||
assert config.read_text(encoding="utf-8") == original
|
||||
assert "自定义" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_codex_install_preserves_existing_user_tables(tmp_path):
|
||||
path = tmp_path / "config.toml"
|
||||
path.write_text(
|
||||
@@ -277,25 +311,6 @@ def test_codex_inline_environment_sync_is_idempotent(tmp_path):
|
||||
)
|
||||
|
||||
|
||||
def test_custom_mcp_wrapper_is_left_unchanged(tmp_path):
|
||||
path = tmp_path / "mcp.json"
|
||||
original = '{"mcpServers":{"artemis":{"command":"uv","args":["run","custom"]}}}'
|
||||
path.write_text(original, encoding="utf-8")
|
||||
assert (
|
||||
update.refresh_json(
|
||||
path,
|
||||
("mcpServers",),
|
||||
tmp_path,
|
||||
"new-python",
|
||||
("mobile_run_task",),
|
||||
install_missing=True,
|
||||
template={"command": "new-python", "args": ["-m", "mcp_server"], "env": {}},
|
||||
)
|
||||
is None
|
||||
)
|
||||
assert path.read_text(encoding="utf-8") == original
|
||||
|
||||
|
||||
def test_frontend_fingerprint_detects_lock_source_and_deleted_assets(tmp_path):
|
||||
ui = tmp_path / "apps/showcase_ui"
|
||||
(ui / "src").mkdir(parents=True)
|
||||
|
||||
@@ -301,6 +301,32 @@ async def test_configured_retry_budget_is_shared_by_identical_fallback(monkeypat
|
||||
assert llm_service._FAILED_ENDPOINTS.get() is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_429_waits_one_minute_then_retries(monkeypatch):
|
||||
from unittest.mock import AsyncMock
|
||||
from artemis.llm.reliability import retry_policy_for
|
||||
|
||||
class RateLimited(Exception):
|
||||
status_code = 429
|
||||
|
||||
calls = 0
|
||||
|
||||
async def call_once():
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
raise RateLimited("Too Many Requests")
|
||||
return "recovered"
|
||||
|
||||
sleep = AsyncMock()
|
||||
monkeypatch.setattr(llm_service, "retry_policy_for", retry_policy_for)
|
||||
monkeypatch.setattr(llm_service.asyncio, "sleep", sleep)
|
||||
monkeypatch.setattr(llm_service.settings, "OPENAI_RATE_LIMIT_RETRY_SECONDS", 60)
|
||||
assert await llm_service._run_with_recovery(call_once, max_retries=1) == "recovered"
|
||||
assert calls == 2
|
||||
sleep.assert_awaited_once_with(60)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_deadline_cancels_attempt_and_respects_zero_retries(monkeypatch):
|
||||
import asyncio
|
||||
|
||||
@@ -49,6 +49,15 @@ def test_non_retryable_categories_get_single_attempt():
|
||||
assert retry_policy_for(FailureCategory.RATE_LIMIT).max_attempts > 1
|
||||
|
||||
|
||||
def test_too_many_requests_uses_one_minute_retry_delay(monkeypatch):
|
||||
from artemis.config.settings import settings
|
||||
|
||||
monkeypatch.setattr(settings, "OPENAI_RATE_LIMIT_RETRY_SECONDS", 60)
|
||||
failure = classify_failure(RuntimeError("Too Many Requests"))
|
||||
assert failure.category is FailureCategory.RATE_LIMIT
|
||||
assert retry_policy_for(failure.category).delay_for(1) == 60
|
||||
|
||||
|
||||
def test_breaker_only_trips_on_transient_categories():
|
||||
breaker = CircuitBreaker(threshold=2, cooldown_seconds=10.0)
|
||||
auth = classify_failure(RuntimeError("401 unauthorized"))
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
"""The compatibility entry point follows the commands registered by Typer."""
|
||||
|
||||
from artemis.main import normalize_argv
|
||||
|
||||
|
||||
def test_registered_commands_and_flags_are_not_treated_as_goals():
|
||||
for token in ("ui", "status", "helper", "restart", "stop", "--help", "--version", "-v"):
|
||||
assert normalize_argv(["artemis", token]) == ["artemis", token]
|
||||
|
||||
|
||||
def test_bare_goal_still_uses_run_command():
|
||||
assert normalize_argv(["artemis", "打开设置"]) == ["artemis", "run", "打开设置"]
|
||||
@@ -132,3 +132,23 @@ def test_packaging_rejects_uncommitted_changes_and_credentials(repository, tmp_p
|
||||
)
|
||||
with pytest.raises(ValueError, match="credential-free HTTPS"):
|
||||
module.package_source(root, tmp_path / "source.zip")
|
||||
|
||||
|
||||
def test_archive_checks_out_chinese_batch_with_crlf_and_clean_git(repository, tmp_path):
|
||||
root, _, _, _ = repository
|
||||
project_root = Path(__file__).resolve().parents[2]
|
||||
(root / ".gitattributes").write_bytes((project_root / ".gitattributes").read_bytes())
|
||||
# The Git blob uses LF; the archive must contain the CRLF working-tree form.
|
||||
content = (project_root / "start-cn.bat").read_bytes().replace(b"\r\n", b"\n")
|
||||
(root / "start-cn.bat").write_bytes(content)
|
||||
git(root, "add", "--", ".gitattributes", "start-cn.bat")
|
||||
git(root, "commit", "-m", "Windows launcher attributes")
|
||||
archive_path = tmp_path / "source.zip"
|
||||
module.package_source(root, archive_path)
|
||||
with zipfile.ZipFile(archive_path) as archive:
|
||||
packed = archive.read("artemis/start-cn.bat")
|
||||
assert b"\r\n" in packed
|
||||
assert b"\n" not in packed.replace(b"\r\n", b"")
|
||||
assert packed.decode("utf-8").startswith("@echo off\r\n")
|
||||
archive.extractall(tmp_path / "unpacked")
|
||||
assert git(tmp_path / "unpacked/artemis", "status", "--porcelain") == ""
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Task deadlines must apply to every ingress and exclude waits."""
|
||||
|
||||
import json
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from artemis.runtime.task_limits import ExecutionBudget, task_timeout
|
||||
from artemis.runtime.task_result import save_failure_result
|
||||
|
||||
|
||||
def test_deadline_precedence_and_single_task_default(monkeypatch):
|
||||
monkeypatch.setattr("artemis.runtime.task_limits.settings.ARTEMIS_TASK_TIMEOUT_SECONDS", 1800)
|
||||
assert task_timeout({}) == 1800
|
||||
job = {"case": {"task_config": {"timeout_seconds": 120}}}
|
||||
assert task_timeout({}, job, {"timeout_seconds": 300}) == 120
|
||||
assert task_timeout({"runtime_overrides": {"timeout_seconds": 60}}, job) == 60
|
||||
assert task_timeout({}, {}, {"timeout_seconds": 300}) == 300
|
||||
|
||||
|
||||
def test_budget_excludes_device_queue_and_user_pause(monkeypatch):
|
||||
clock = Mock(return_value=0.0)
|
||||
monkeypatch.setattr("artemis.runtime.task_limits.time.monotonic", clock)
|
||||
budget = ExecutionBudget(30, _last=0)
|
||||
budget.update(excluded=True)
|
||||
clock.return_value = 3600
|
||||
assert budget.update(excluded=False) == 30
|
||||
clock.return_value = 3610
|
||||
assert budget.update(excluded=True) == 20
|
||||
clock.return_value = 7200
|
||||
assert budget.update(excluded=False) == 20
|
||||
clock.return_value = 7221
|
||||
assert budget.update() == 0
|
||||
|
||||
|
||||
def test_failure_report_replaces_empty_result_atomically(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("artemis.runtime.task_result.TRACES_PATH", tmp_path)
|
||||
directory = tmp_path / "test-task"
|
||||
directory.mkdir()
|
||||
(directory / "result.json").touch()
|
||||
save_failure_result("test-task", "设备截图失败:ADB gone", code="device_observation_failed")
|
||||
for name in ("result.json", "run_outcome.json"):
|
||||
payload = json.loads((directory / name).read_text(encoding="utf-8"))
|
||||
assert payload["task_status"] == "failed"
|
||||
assert "ADB gone" in payload["error"]
|
||||
assert not list(directory.glob("*.tmp"))
|
||||
(directory / "result.json").write_text(
|
||||
'{"tests":{"checkpoint":{"passed":false}}}', encoding="utf-8"
|
||||
)
|
||||
save_failure_result("test-task", "任务超时", code="task_timeout")
|
||||
payload = json.loads((directory / "result.json").read_text(encoding="utf-8"))
|
||||
assert payload["tests"]["checkpoint"]["passed"] is False
|
||||
|
||||
|
||||
def test_checker_outcome_cannot_trigger_worker_cleanup(tmp_path, monkeypatch):
|
||||
from artemis.runtime.task_result import read_worker_outcome
|
||||
|
||||
monkeypatch.setattr("artemis.runtime.task_result.TRACES_PATH", tmp_path)
|
||||
directory = tmp_path / "checking"
|
||||
directory.mkdir()
|
||||
for status in ("failed", "completed"):
|
||||
(directory / "run_outcome.json").write_text(
|
||||
json.dumps({"task_status": status}), encoding="utf-8"
|
||||
)
|
||||
assert read_worker_outcome("checking") == {}
|
||||
save_failure_result("checking", "设备故障")
|
||||
assert read_worker_outcome("checking")["worker_finalized"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_single_task_hung_worker_is_killed_and_records_timeout(tmp_path, monkeypatch):
|
||||
import asyncio
|
||||
from apps.admin_console.services.task_queue_service import TaskQueueService
|
||||
|
||||
monkeypatch.setattr("artemis.runtime.task_result.TRACES_PATH", tmp_path)
|
||||
monkeypatch.setattr(
|
||||
"apps.admin_console.services.automation_store.store", Mock(job=Mock(return_value=None))
|
||||
)
|
||||
monkeypatch.setattr("artemis.runtime.task_limits.task_timeout", lambda *args: 0)
|
||||
monkeypatch.setattr("artemis.runtime.task_control.read_state", lambda *args: {})
|
||||
monkeypatch.setattr(TaskQueueService, "_sync_control_states", lambda: None)
|
||||
stopped = asyncio.Event()
|
||||
|
||||
async def wait(proc):
|
||||
await stopped.wait()
|
||||
return -9
|
||||
|
||||
kill = AsyncMock(side_effect=lambda proc: stopped.set())
|
||||
monkeypatch.setattr(TaskQueueService, "_wait_for_worker_process", wait)
|
||||
monkeypatch.setattr(TaskQueueService, "_terminate_worker_process", kill)
|
||||
item = {"session_id": "hung-single"}
|
||||
assert (
|
||||
await asyncio.wait_for(
|
||||
TaskQueueService._await_worker_with_budget(Mock(), item, "hung-single"), 2
|
||||
)
|
||||
== -9
|
||||
)
|
||||
kill.assert_awaited_once()
|
||||
assert item["timed_out"]
|
||||
result = json.loads((tmp_path / "hung-single" / "result.json").read_text(encoding="utf-8"))
|
||||
assert result["code"] == "task_timeout"
|
||||
assert "不含暂停/排队" in result["error"]
|
||||
|
||||
|
||||
def test_standalone_mcp_worker_deadline_kills_before_report_write(monkeypatch):
|
||||
from mcp_server.tools import task_runner
|
||||
|
||||
proc = Mock(pid=999999, poll=Mock(return_value=None))
|
||||
monkeypatch.setattr(task_runner.trace_store, "read_status", lambda *_: {"status": "running"})
|
||||
monkeypatch.setattr(task_runner, "read_state", lambda *_: {"status": "running"})
|
||||
monkeypatch.setattr(
|
||||
task_runner.DeviceExecutionLock,
|
||||
"get_active_owners",
|
||||
lambda: {"device": Mock(session_id="mcp-hung")},
|
||||
)
|
||||
events = []
|
||||
monkeypatch.setattr(task_runner, "_kill_process_tree", lambda *_: events.append("kill"))
|
||||
|
||||
def fail_report(*args, **kwargs):
|
||||
events.append("report")
|
||||
raise OSError("disk full")
|
||||
|
||||
monkeypatch.setattr(task_runner, "save_failure_result", fail_report)
|
||||
task_runner._watch_execution(proc, "mcp-hung", 0)
|
||||
assert events == ["kill", "report"]
|
||||
|
||||
|
||||
def test_mcp_finalized_worker_is_reclaimed_without_overwriting_device_failure(monkeypatch):
|
||||
from mcp_server.tools import task_runner
|
||||
|
||||
outcome = {"task_status": "failed", "error": "设备断连", "code": "device_observation_failed"}
|
||||
monkeypatch.setattr(task_runner, "read_worker_outcome", lambda _: outcome)
|
||||
monkeypatch.setattr(task_runner.trace_store, "read_status", lambda _: {"status": "running"})
|
||||
updated = Mock()
|
||||
monkeypatch.setattr(task_runner.trace_store, "update_trace_status", updated)
|
||||
kill = Mock()
|
||||
monkeypatch.setattr(task_runner, "_kill_process_tree", kill)
|
||||
clock = Mock(side_effect=[0.0, 0.1])
|
||||
monkeypatch.setattr(task_runner.time, "monotonic", clock)
|
||||
task_runner._watch_execution(
|
||||
Mock(pid=123, poll=Mock(return_value=None)), "test-mcp", 1800, cleanup_limit=0.01
|
||||
)
|
||||
kill.assert_called_once_with(123)
|
||||
updated.assert_called_once_with("test-mcp", "failed", error="设备断连")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("finalized", [False, True])
|
||||
async def test_real_hung_process_stops_and_next_worker_can_complete(
|
||||
tmp_path, monkeypatch, finalized
|
||||
):
|
||||
import asyncio
|
||||
import sys
|
||||
from apps.admin_console.services.task_queue_service import TaskQueueService
|
||||
|
||||
monkeypatch.setattr("artemis.runtime.task_result.TRACES_PATH", tmp_path)
|
||||
monkeypatch.setattr(
|
||||
"apps.admin_console.services.automation_store.store", Mock(job=Mock(return_value=None))
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"artemis.runtime.task_limits.task_timeout",
|
||||
lambda item, *args: 0 if item["session_id"] == "hung" and not finalized else 30,
|
||||
)
|
||||
monkeypatch.setattr("artemis.runtime.task_control.read_state", lambda *args: {})
|
||||
monkeypatch.setattr(TaskQueueService, "_sync_control_states", lambda: None)
|
||||
item = {"session_id": "hung", "runtime_overrides": {"worker_cleanup_timeout_seconds": 0.01}}
|
||||
if finalized:
|
||||
save_failure_result("hung", "设备截图失效", code="device_observation_failed")
|
||||
hung = await asyncio.create_subprocess_exec(
|
||||
sys.executable, "-c", "import time; time.sleep(3600)"
|
||||
)
|
||||
try:
|
||||
code = await asyncio.wait_for(
|
||||
TaskQueueService._await_worker_with_budget(hung, item, "hung"), 10
|
||||
)
|
||||
assert code != 0
|
||||
assert hung.returncode is not None
|
||||
result = json.loads((tmp_path / "hung" / "result.json").read_text(encoding="utf-8"))
|
||||
assert result["code"] == ("device_observation_failed" if finalized else "task_timeout")
|
||||
assert bool(item.get("timed_out")) is not finalized
|
||||
next_worker = await asyncio.create_subprocess_exec(sys.executable, "-c", "pass")
|
||||
assert (
|
||||
await asyncio.wait_for(
|
||||
TaskQueueService._await_worker_with_budget(
|
||||
next_worker, {"session_id": "next"}, "next"
|
||||
),
|
||||
10,
|
||||
)
|
||||
== 0
|
||||
)
|
||||
finally:
|
||||
if hung.returncode is None:
|
||||
hung.kill()
|
||||
await hung.wait()
|
||||
@@ -207,6 +207,30 @@ async def test_analyzer_clip_stays_continuous_across_orientation_segments(tmp_pa
|
||||
assert len(frames) >= 20
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_analyzer_clip_pins_requested_fps_across_recording_gap(tmp_path):
|
||||
ffmpeg = get_ffmpeg_path()
|
||||
first = tmp_path / "first.mkv"
|
||||
second = tmp_path / "second.mkv"
|
||||
output = tmp_path / "clip.mp4"
|
||||
_make_solid_clip(ffmpeg, "red", "108x242", 1.0, first)
|
||||
_make_solid_clip(ffmpeg, "blue", "242x108", 1.0, second)
|
||||
assert await render_timeline_clip(
|
||||
[{"path": first, "start": 0.0, "end": 1.0}, {"path": second, "start": 1.5, "end": 2.5}],
|
||||
0.0,
|
||||
2.5,
|
||||
output,
|
||||
canvas_width=180,
|
||||
canvas_height=320,
|
||||
fps=7,
|
||||
)
|
||||
capture = cv2.VideoCapture(str(output))
|
||||
try:
|
||||
assert capture.get(cv2.CAP_PROP_FPS) == pytest.approx(7.0, abs=0.1)
|
||||
finally:
|
||||
capture.release()
|
||||
|
||||
|
||||
def test_timeline_pieces_fill_restart_gaps(tmp_path):
|
||||
"""Holes between segments (and a window starting in one) become black gaps."""
|
||||
first = tmp_path / "recording.mkv"
|
||||
|
||||
@@ -146,3 +146,30 @@ def test_batch_preserves_exit_code_and_handles_space_and_exclamation_paths(tmp_p
|
||||
timeout=30,
|
||||
)
|
||||
assert result.returncode == exit_code, result.stdout + result.stderr
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exit_code", [0, 7])
|
||||
def test_chinese_batch_prints_complete_lines_and_preserves_exit_code(tmp_path, exit_code):
|
||||
root = tmp_path / "source with spaces!"
|
||||
root.mkdir()
|
||||
shutil.copy2(ROOT / "start-cn.bat", root / "start-cn.bat")
|
||||
(root / "start.bat").write_bytes(
|
||||
f'@echo off\r\nif not "%~1"=="-Mirror" exit /b 19\r\n'
|
||||
f'if not "%~2"=="China" exit /b 20\r\nexit /b {exit_code}\r\n'.encode("ascii")
|
||||
)
|
||||
result = subprocess.run(
|
||||
["cmd.exe", "/d", "/c", "call", str(root / "start-cn.bat")],
|
||||
capture_output=True,
|
||||
timeout=15,
|
||||
)
|
||||
assert result.returncode == exit_code, result.stdout + result.stderr
|
||||
assert not result.stderr, result.stderr
|
||||
assert "镜像地址和下载超时可在" in result.stdout.decode("utf-8")
|
||||
help_result = subprocess.run(
|
||||
["cmd.exe", "/d", "/c", "call", str(root / "start-cn.bat"), "-Help"],
|
||||
capture_output=True,
|
||||
timeout=15,
|
||||
)
|
||||
assert help_result.returncode == 0
|
||||
assert not help_result.stderr
|
||||
assert "Codex MCP" in help_result.stdout.decode("utf-8")
|
||||
@@ -47,6 +47,17 @@ else:
|
||||
del sys.modules["artemis.graph.state"]
|
||||
|
||||
|
||||
import base64
|
||||
from io import BytesIO
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def _valid_screen_base64():
|
||||
buffer = BytesIO()
|
||||
Image.new("RGB", (64, 64), "white").save(buffer, format="PNG")
|
||||
return base64.b64encode(buffer.getvalue()).decode("ascii")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_context():
|
||||
"""Create a mock ArtemisContext for testing."""
|
||||
@@ -70,6 +81,7 @@ def mock_context():
|
||||
|
||||
# Mock the ADB client for Android
|
||||
mock_response = Mock()
|
||||
mock_response.base64 = _valid_screen_base64()
|
||||
mock_response.json.return_value = {"elements": []}
|
||||
ctx.ui_adb_client.get_screen_data = Mock(return_value=mock_response)
|
||||
ctx.ui_adb_client.get_hierarchy = Mock(
|
||||
@@ -288,6 +300,7 @@ class TestFocusElementIfNeeded:
|
||||
focused_element["attributes"]["focused"] = "true"
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.base64 = _valid_screen_base64()
|
||||
mock_response.json.return_value = {"elements": [focused_element]}
|
||||
mock_context.ui_adb_client.get_screen_data = Mock(return_value=mock_response)
|
||||
mock_find_element.return_value = focused_element["attributes"]
|
||||
@@ -366,6 +379,7 @@ class TestFocusElementIfNeeded:
|
||||
}
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.base64 = _valid_screen_base64()
|
||||
mock_response.json.return_value = {"elements": [element_from_text]}
|
||||
mock_context.ui_adb_client.get_screen_data = Mock(return_value=mock_response)
|
||||
mock_find_id.return_value = element_from_id
|
||||
@@ -401,6 +415,7 @@ class TestFocusElementIfNeeded:
|
||||
}
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.base64 = _valid_screen_base64()
|
||||
mock_response.json.return_value = {"elements": [element_with_bounds]}
|
||||
mock_context.ui_adb_client.get_screen_data = Mock(return_value=mock_response)
|
||||
mock_find_text.return_value = element_with_bounds["attributes"]
|
||||
@@ -428,6 +443,7 @@ class TestFocusElementIfNeeded:
|
||||
"""Test failure when no locator can find an element."""
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.base64 = _valid_screen_base64()
|
||||
mock_response.json.return_value = {"elements": []}
|
||||
mock_context.ui_adb_client.get_screen_data = Mock(return_value=mock_response)
|
||||
with (
|
||||
|
||||
Reference in new issue
Block a user