feat: add prompt-first batches and pausable task guidance

This commit is contained in:
chenmingxuan committed 2026-09-23 22:59:48 +08:00
1 parent cec9898e4c
commit 057f0c3050
44 files changed
+1070 -181

No files matched your search

+1
View File
@@ -14,6 +14,7 @@ OPENAI_REASONING_EFFORT=high
# 功能开关:保存后对新任务生效
ARTEMIS_VIDEO_ENABLED=false
ARTEMIS_RECORDING_ENABLED=false
ARTEMIS_OUTPUTTER_ENABLED=true
ARTEMIS_STEP_SUMMARIZER_ENABLED=true
# Pro 连续无工具调用或参数校验失败的最大次数;耗尽后明确失败,避免空转。
+4 -4
View File
@@ -6,8 +6,8 @@
- **自然语言任务**:Flash 适合明确的操作流程,Pro 适合需要计划、分支探索和验证的任务。
- **可保存的模型配置**:支持自定义接口、模型、思考强度和超时重试,以及视频分析与录屏开关。
- **批量测试**:下载 Excel 模板、校验导入、顺序执行,支持暂停、继续和复制失败项。
- **报告与历史**:导出 Word、Excel 和批次 ZIP;历史按日期分组,支持搜索和筛选。
- **批量测试**:按新版 Excel 模板导入提示词,每项可独立指定模型、模式和参数;任务可暂停、补充指导并继续。
- **报告与历史**:导出 Word、Excel、步骤截图、录屏和任务资料 ZIP;历史按日期分组,支持搜索和筛选。
- **AI 接入**:随源码提供 MCP 与 Skill,让 AI 完成设备诊断、任务执行、历史分析和报告交付。
## 安装与启动
@@ -34,7 +34,7 @@ cd Artemis-Eapil
.\start.bat -SkipUpdate # 跳过 Git 源码更新检查
```
镜像与下载超时在 [config/downloads.json](config/downloads.json) 中设置。scrcpy 默认从 GitHub 下载,无法访问时可配置可信的 scrcpy_url 或预先安装。
镜像与下载超时在 [config/downloads.json](config/downloads.json) 中设置。国内脚本使用山东大学镜像安装 scrcpy,并校验官方 SHA256;也可预先安装或自定义 HTTPS 下载地址。
### macOS / Linux
@@ -69,7 +69,7 @@ uv run artemis mcp --install codex
## 更新与排错
Windows 用户再次运行一键脚本时,会检查 Git 上游、项目依赖、前端构建和已有 MCP 配置,仅对源码做快进更新。本地修改、分支分叉或网络认证失败时保留本地版本;有任务执行或排队时停止更新启动。更新后需在 AI 客户端重新加载 MCP。
Windows 用户再次运行一键脚本时,会先检查 Git 上游;即使 `start.bat` 或 `start-cn.bat` 本身被更新,也会重新启动新版脚本,再检查项目依赖、前端构建和已有 MCP 配置。源码只做快进更新。本地修改、分支分叉或网络认证失败时保留本地版本;有任务执行或排队时停止更新启动。更新后需在 AI 客户端重新加载 MCP。
ZIP 安装可继续运行;需要自动拉取源码时,请改用 Git 克隆安装。遇到启动或设备问题,在项目目录运行 `uv run artemis doctor`。
+1 -1
View File
@@ -136,7 +136,7 @@ class ServerState:
return {
str(run.get("lock_key") or run["device_id"])
for run in self.active_runs.values()
if run.get("device_id")
if run.get("device_id") and not run.get("parked")
}
@property
@@ -422,7 +422,7 @@ class SessionRepository:
return count
def reconcile_orphaned_sessions(self) -> int:
"""Mark running sessions with no live worker process as failed.
"""Mark running sessions with no live worker process as interrupted.
This is safe to call both during startup and immediately after a
forced server stop: live workers, including workers owned by another
@@ -442,14 +442,14 @@ class SessionRepository:
cursor.execute(
"UPDATE sessions SET status = ?, end_time = ? "
"WHERE session_id = ? AND status = ?",
("failed", now, row["session_id"], "running"),
("interrupted", now, row["session_id"], "running"),
)
count += cursor.rowcount
try:
if trace_store.read_status(str(row["session_id"])):
trace_store.update_trace_status(
str(row["session_id"]),
"failed",
"interrupted",
error="Process terminated prior to server startup.",
)
except OSError as exc:
+18 -5
View File
@@ -146,8 +146,13 @@ def report(session_id: str):
@router.get("/reports/{session_id}/{fmt}")
async def download_report(session_id: str, fmt: Literal["docx", "xlsx"]):
async def download_report(
session_id: str, fmt: Literal["docx", "xlsx", "screenshots", "video", "all"]
):
try:
if fmt in {"screenshots", "video", "all"}:
path = await asyncio.to_thread(report_export.export_session_media, session_id, fmt)
return FileResponse(path, filename=f"任务{session_id[:8]}-{fmt}.zip")
paths = await asyncio.to_thread(report_export.export_session, session_id, [fmt])
return FileResponse(paths[0], filename=f"任务报告-{session_id[:8]}.{fmt}")
except ValueError as exc:
@@ -167,21 +172,29 @@ def history(
query = """WITH history AS (
SELECT s.session_id, s.initial_goal, s.start_time, s.end_time,
COALESCE(j.status,s.status) status, COALESCE(json_extract(j.payload,'$.case.name'),s.initial_goal) name,
COALESCE(json_extract(j.payload,'$.case.tags'),'') tags
COALESCE(json_extract(j.payload,'$.case.tags'),'') tags,
COALESCE(json_extract(j.payload,'$.case.case_id'),'') case_id,
COALESCE(json_extract(j.payload,'$.case.category'),'') category,
COALESCE(json_extract(j.payload,'$.case.subcategory'),'') subcategory,
COALESCE(json_extract(j.payload,'$.case.preconditions'),'') preconditions
FROM sessions s LEFT JOIN automation_jobs j ON s.session_id=j.session_id
UNION ALL
SELECT j.session_id,json_extract(j.payload,'$.case.goal'),
COALESCE(json_extract(j.payload,'$.started_at'),json_extract(j.payload,'$.created_at')),
json_extract(j.payload,'$.ended_at'),j.status,json_extract(j.payload,'$.case.name'),
COALESCE(json_extract(j.payload,'$.case.tags'),'')
COALESCE(json_extract(j.payload,'$.case.tags'),''),
COALESCE(json_extract(j.payload,'$.case.case_id'),''),
COALESCE(json_extract(j.payload,'$.case.category'),''),
COALESCE(json_extract(j.payload,'$.case.subcategory'),''),
COALESCE(json_extract(j.payload,'$.case.preconditions'),'')
FROM automation_jobs j WHERE NOT EXISTS (SELECT 1 FROM sessions s WHERE s.session_id=j.session_id)
) """
where = " WHERE status IN ('completed','success','failed','cancelled','interrupted','skipped')"
args = []
if q:
where += " AND (name LIKE ? ESCAPE '\\' OR initial_goal LIKE ? ESCAPE '\\' OR session_id LIKE ? ESCAPE '\\' OR tags LIKE ? ESCAPE '\\')"
where += " AND (name LIKE ? ESCAPE '\\' OR initial_goal LIKE ? ESCAPE '\\' OR session_id LIKE ? ESCAPE '\\' OR tags LIKE ? ESCAPE '\\' OR case_id LIKE ? ESCAPE '\\' OR category LIKE ? ESCAPE '\\' OR subcategory LIKE ? ESCAPE '\\' OR preconditions LIKE ? ESCAPE '\\')"
escaped = "%" + q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
args.extend([escaped] * 4)
args.extend([escaped] * 8)
for condition, value in [
("status = ?", status or None),
("start_time >= ?", start),
+61
View File
@@ -18,8 +18,16 @@ import json
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from artemis.core.diagnostics import readiness_engine
from artemis.runtime import DeviceExecutionLock, device_pool
from artemis.runtime.task_control import (
add_instruction,
control_dir,
read_state,
request_pause,
request_resume,
)
try:
from admin_console.core.state import state
@@ -42,6 +50,48 @@ except ImportError:
router = APIRouter(tags=["tasks"])
class GuidanceRequest(BaseModel):
instruction: str = Field(min_length=1, max_length=10000)
def _live_task(session_id: str) -> dict:
from apps.admin_console.services.automation_store import identifier
identifier(session_id)
run = state.active_runs.get(session_id)
if not run or run["process"].returncode is not None:
raise HTTPException(409, "任务未在执行,无法暂停或指导")
return run
@router.post("/api/tasks/{session_id}/pause")
def pause_running_task(session_id: str):
_live_task(session_id)
request_pause(session_id)
return {"session_id": session_id, "status": "pausing"}
@router.post("/api/tasks/{session_id}/guide")
def guide_running_task(session_id: str, value: GuidanceRequest):
_live_task(session_id)
add_instruction(session_id, value.instruction, source="web")
if (
read_state(session_id).get("status") == "task_paused"
or (control_dir(session_id) / "pause.request").exists()
):
request_resume(session_id)
return {"session_id": session_id, "status": "guidance_submitted"}
@router.post("/api/tasks/{session_id}/continue")
def continue_running_task(session_id: str):
_live_task(session_id)
if read_state(session_id).get("status") != "task_paused":
raise HTTPException(409, "任务尚未暂停")
request_resume(session_id)
return {"session_id": session_id, "status": "resuming"}
@router.get("/api/tasks/presets")
async def get_task_presets(
category: str = "recommended",
@@ -285,6 +335,17 @@ async def get_status():
}
for owner in active_owners.values()
]
for sid, run in state.active_runs.items():
if run.get("parked"):
active_tasks.append(
{
"session_id": sid,
"device_id": run.get("device_id"),
"goal": run.get("goal"),
"pid": getattr(run.get("process"), "pid", None),
"status": read_state(sid).get("status") or "task_paused",
}
)
running_sid = (
(global_owner.session_id if global_owner else None)
+5 -3
View File
@@ -152,7 +152,7 @@ async def on_startup():
cleaned = session_repo.cleanup_orphans_on_startup()
if cleaned > 0:
print(f"[ServerStartup] Marked {cleaned} orphaned running session(s) as failed.")
print(f"[ServerStartup] Marked {cleaned} orphaned running session(s) as interrupted.")
# Workers killed together with a previous daemon never remuxed their
# recordings; publish whatever raw files they left behind.
asyncio.create_task(asyncio.to_thread(task_queue_service.recover_orphaned_recordings_on_launch))
@@ -176,7 +176,9 @@ async def on_shutdown():
owned_session_ids = {
str(item["session_id"])
for item in state.queue_items
if isinstance(item, dict) and item.get("status") == "running" and item.get("session_id")
if isinstance(item, dict)
and item.get("status") in {"running", "task_paused", "resuming"}
and item.get("session_id")
}
worker = state.worker_task
@@ -212,7 +214,7 @@ async def on_shutdown():
state.current_process = None
state.queue_items.clear()
for session_id in owned_session_ids:
session_repo.update_session_status(session_id, "cancelled")
session_repo.update_session_status(session_id, "interrupted")
from apps.admin_console.services.automation_service import background_tasks
@@ -41,7 +41,7 @@ def register_submission(item: dict, case: dict | None = None, formats=None, **ex
def recover_batches():
"""Recovery never silently repeats a potentially side-effecting interrupted task."""
for job in store.jobs():
if job["status"] in {"running", "queued", "finalizing"}:
if job["status"] in {"running", "queued", "finalizing", "task_paused", "resuming"}:
job.update(
status=job.get("terminal_status", "interrupted"),
error=job.get("error")
@@ -64,7 +64,7 @@ def recover_batches():
status="paused", reason="服务已重启;请检查中断任务,点击继续执行未开始任务"
)
for job in store.jobs(batch["id"]):
if job["status"] in {"running", "queued", "finalizing"}:
if job["status"] in {"running", "queued", "finalizing", "task_paused", "resuming"}:
job.update(
status="interrupted", error="服务中断,需手动重跑", ended_at=time.time()
)
@@ -89,11 +89,15 @@ async def on_terminal(item: dict, status: str):
try:
job["status"] = status
report = await asyncio.to_thread(session_report, sid)
if report["verdict"] == "验证失败" or report["outcome"].get("task_status") in {
if status != "interrupted" and (
report["verdict"] == "验证失败"
or report["outcome"].get("task_status")
in {
"blocked",
"partial",
"failed",
}:
}
):
job["status"] = "failed"
job["error"] = "任务执行结束,但业务检查未通过"
# Persist terminal classification before export, but the coordinator waits for finalization.
@@ -121,11 +125,18 @@ async def tick_batches():
batch.update(status="paused", reason="模型请求已暂停,请先恢复模型连接再继续批次")
store.put("batch", batch["id"], batch)
for job in active:
limit = int(batch.get("timeout_seconds", 1800))
limit = int(
(job.get("case", {}).get("task_config") or {}).get("timeout_seconds")
or batch.get("timeout_seconds", 1800)
)
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"):
excluded += time.time() - float(task_state["non_execution_started_at"])
if (
job["status"] != "finalizing"
and not job.get("timed_out")
and time.time() - job.get("started_at", time.time()) > limit
and time.time() - job.get("started_at", time.time()) - excluded > limit
):
job.update(error="任务达到批次配置的执行时限", timed_out=True)
store.save_job(job)
@@ -142,6 +153,11 @@ async def tick_batches():
job.update(status="skipped", error="本批次设置为失败即停止", ended_at=time.time())
store.save_job(job)
pending = []
if not pending and any(
j["status"] in {"task_paused", "resuming", "running", "queued", "finalizing"}
for j in jobs
):
continue
if not pending:
batch.update(status="completed", ended_at=time.time())
try:
@@ -156,7 +172,9 @@ async def tick_batches():
batch.update(status="paused", reason="模型请求已暂停,请先恢复模型连接再继续批次")
store.put("batch", batch["id"], batch)
continue
serial = batch.get("device_serial")
job = pending[0]
options = job["case"].get("task_config") or {}
serial = options.get("device_serial") or batch.get("device_serial")
devices = await device_pool.try_list_devices_async()
rejection = (
None
@@ -169,26 +187,28 @@ async def tick_batches():
continue
if (store.get("batch", batch["id"]) or {}).get("status") != "running":
continue
job = pending[0]
job.update(status="queued", started_at=time.time())
store.save_job(job)
try:
response = await task_queue_service.enqueue_tasks(
[case_goal(job["case"])],
profile=batch.get("profile", "flash"),
expected_output="用简体中文报告实际结果与证据。明确区分执行完成、验证通过、验证失败和前置条件未满足。"
+ job["case"].get("expected", ""),
profile=options.get("profile") or batch.get("profile", "flash"),
expected_output="用简体中文报告实际结果与证据。明确区分执行完成、验证通过、验证失败和前置条件未满足。",
enable_outputter=True,
device_serial=serial,
verification_level=options.get("verification_level"),
explorer_mode=options.get("explorer_mode"),
locked_app_package=options.get("locked_app_package"),
runtime_overrides=options,
session_id=job["session_id"],
ingress="batch",
)
if response.get("status") == "rejected" or not response.get("tasks"):
raise ValueError(response.get("error") or "队列拒绝接收任务")
bound = response["tasks"][0].get("device_serial")
if bound and not serial:
batch["device_serial"] = bound
store.put("batch", batch["id"], batch)
if bound:
job["device_serial"] = bound
store.save_job(job)
except Exception as exc:
job.update(status="pending", error=str(exc)[:500])
store.save_job(job)
@@ -129,6 +129,7 @@ def persist_environment(values: dict[str, str]) -> None:
class CapabilitySettings(BaseModel):
video_enabled: bool = False
recording_enabled: bool = False
outputter_enabled: bool = True
step_summarizer_enabled: bool = True
@@ -136,6 +137,7 @@ class CapabilitySettings(BaseModel):
def get_capability_settings() -> CapabilitySettings:
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,
)
@@ -144,6 +146,7 @@ def get_capability_settings() -> CapabilitySettings:
def save_capability_settings(update: CapabilitySettings) -> CapabilitySettings:
values = {
"ARTEMIS_VIDEO_ENABLED": update.video_enabled,
"ARTEMIS_RECORDING_ENABLED": update.recording_enabled,
"ARTEMIS_OUTPUTTER_ENABLED": update.outputter_enabled,
"ARTEMIS_STEP_SUMMARIZER_ENABLED": update.step_summarizer_enabled,
}
@@ -32,6 +32,8 @@ STATUS = {
"pending": "待执行",
"queued": "已排队",
"running": "执行中",
"task_paused": "等待指导",
"resuming": "等待设备恢复",
}
@@ -107,6 +109,11 @@ def session_report(session_id: str, db_path=None) -> dict:
"error": meta.get("error", ""),
"device": meta.get("device_serial") or text(session.get("device_info") or ""),
"tags": case.get("tags", ""),
"case_id": case.get("case_id", ""),
"category": case.get("category", ""),
"subcategory": case.get("subcategory", ""),
"preconditions": case.get("preconditions", ""),
"task_config": case.get("task_config") or {},
}
@@ -121,6 +128,11 @@ def time_text(value) -> str:
def metadata(report: dict) -> list[tuple]:
return [
("任务编号", report["id"]),
("用例编号", report.get("case_id") or "—"),
("检测项目", report.get("category") or "—"),
("测试子项", report.get("subcategory") or "—"),
("前置约束提取", report.get("preconditions") or "—"),
("任务配置", text(report.get("task_config") or {})),
("任务目标", report["goal"]),
("预期结果", report["expected"] or "未指定"),
("执行状态", STATUS.get(report["status"], report["status"])),
@@ -279,6 +291,88 @@ def export_session(session_id: str, formats: list[str] | None = None) -> list[Pa
return outputs
def session_screenshots(report: dict) -> list[tuple[Path, str]]:
"""Ordered, path-checked pre/post observations for each recorded step."""
screenshots: list[tuple[Path, str]] = []
for index, step in enumerate(report["steps"], 1):
for phase, key in (("操作前", "pre_image_name"), ("操作后", "post_image_name")):
source = image_path(step.get(key))
if source:
screenshots.append(
(
source,
f"{index:04d}_步骤{int(step.get('step_number') or index):04d}_{phase}{source.suffix.lower()}",
)
)
return screenshots
def session_videos(report: dict) -> list[tuple[Path, str]]:
"""Find only verified files attached to this session's recording directory."""
session = SessionRepository().get_session_by_id(report["id"]) or {}
recording = SessionRepository().get_video_recording_for_session(report["id"]) or {}
candidate = recording.get("local_video_path") or session.get("video_filepath")
if not candidate:
return []
source = Path(str(candidate)).resolve()
session_root = (TRACES_PATH / identifier(report["id"])).resolve()
if not source.is_relative_to(session_root):
return []
# A resumed recording lives under resume_NNNN; the initial recording is
# in its parent, with any later segments in sibling resume folders.
directory = source.parent
if directory.name.startswith("resume_"):
directory = directory.parent
if not directory.is_dir() or not directory.is_relative_to(session_root):
return []
paths = [
p
for p in directory.rglob("*.mp4")
if p.is_file()
and p.resolve().is_relative_to(directory.resolve())
and (p.parent == directory or p.parent.name.startswith("resume_"))
]
paths.sort(key=lambda p: (0 if p.parent == directory else 1, p.parent.name, p.name))
return [(path, f"{i:04d}_{path.parent.name}_{path.name}") for i, path in enumerate(paths, 1)]
def export_session_media(session_id: str, kind: str) -> Path:
if kind not in {"screenshots", "video", "all"}:
raise ValueError("不支持的下载类型")
with EXPORT_LOCK:
report = session_report(session_id)
target_dir = EXPORT_ROOT / "sessions" / identifier(session_id)
target_dir.mkdir(parents=True, exist_ok=True)
images = session_screenshots(report) if kind in {"screenshots", "all"} else []
videos = session_videos(report) if kind in {"video", "all"} else []
if kind == "video" and not videos:
raise ValueError("此任务没有已保存的录屏")
if kind == "screenshots" and not images:
raise ValueError("此任务没有可下载的步骤截图")
target = target_dir / f"{kind}.zip"
temporary = target_dir / f".{kind}.tmp"
manifest = {
"session_id": session_id,
"name": report["name"],
"status": report["status"],
"verdict": report["verdict"],
"screenshots": [name for _, name in images],
"videos": [name for _, name in videos],
"note": "录屏按时间顺序分段;暂停期间未占用设备,也未录制其他任务。",
}
with zipfile.ZipFile(temporary, "w", zipfile.ZIP_DEFLATED) as archive:
if kind == "all":
for path in export_session(session_id, ["docx", "xlsx"]):
archive.write(path, path.name)
for source, name in images:
archive.write(source, f"步骤截图/{name}")
for source, name in videos:
archive.write(source, f"任务录屏/{name}")
archive.writestr("文件清单.json", json.dumps(manifest, ensure_ascii=False, indent=2))
temporary.replace(target)
return target
def export_batch(batch_id: str) -> Path:
with EXPORT_LOCK:
batch = store.get("batch", identifier(batch_id))
+100 -48
View File
@@ -1,4 +1,4 @@
"""Versioned Excel task template and bounded, non-executing import validation."""
"""Validate the prompt-first batch workbook without executing its contents."""
from io import BytesIO
import json
@@ -6,33 +6,53 @@ import os
import zipfile
from openpyxl import Workbook, load_workbook
from openpyxl.styles import Alignment, Font, PatternFill
from openpyxl.utils import get_column_letter
from openpyxl.styles import Font, PatternFill, Alignment
from pydantic import BaseModel, Field, ConfigDict
from pydantic import BaseModel, ConfigDict, Field
HEADERS = {
"任务名称": "name",
"任务目标": "goal",
"预期结果": "expected",
"目标应用": "app",
"前置条件": "preconditions",
"输入参数(JSON)": "parameters",
"标签": "tags",
}
HEADERS = (
"用例编号",
"检测项目",
"测试子项",
"测试用例",
"前置约束提取",
"Artemis提示词",
"任务配置(JSON)",
)
MAX_ROWS = int(os.getenv("ARTEMIS_IMPORT_MAX_ROWS", "1000"))
MAX_BYTES = int(os.getenv("ARTEMIS_IMPORT_MAX_BYTES", "10485760"))
class TaskOptions(BaseModel):
"""Allowlisted per-case overrides; credentials are never accepted in a workbook."""
model_config = ConfigDict(extra="forbid")
profile: str | None = Field(default=None, pattern="^(flash|pro)$")
model: str | None = Field(default=None, min_length=1, max_length=200)
reasoning_effort: str | None = Field(
default=None, pattern="^(default|none|minimal|low|medium|high|xhigh|max)$"
)
device_serial: str | None = Field(default=None, min_length=1, max_length=200)
verification_level: str | None = Field(default=None, pattern="^(off|final|checkpoints|strict)$")
explorer_mode: str | None = Field(default=None, pattern="^(flash|pro|ultra)$")
locked_app_package: str | None = Field(default=None, min_length=1, max_length=200)
record_video: bool | None = None
analyze_video: bool | None = None
request_timeout_seconds: float | None = Field(default=None, gt=0, le=3600)
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)
class TaskCase(BaseModel):
model_config = ConfigDict(extra="forbid")
case_id: str = Field(default="", max_length=200)
category: str = Field(default="", max_length=200)
subcategory: str = Field(default="", max_length=200)
name: str = Field(min_length=1, max_length=200)
goal: str = Field(min_length=1, max_length=10000)
expected: str = Field(default="", max_length=10000)
app: str = Field(default="", max_length=200)
preconditions: str = Field(default="", max_length=10000)
parameters: dict[str, str | int | float | bool] = Field(default_factory=dict)
tags: str = Field(default="", max_length=500)
goal: str = Field(min_length=1, max_length=10000)
task_config: TaskOptions = Field(default_factory=TaskOptions)
def template() -> bytes:
@@ -40,27 +60,54 @@ def template() -> bytes:
ws = wb.active
assert ws is not None
ws.title = "任务"
ws.append(list(HEADERS))
ws.append(HEADERS)
ws.freeze_panes = "A2"
ws.auto_filter.ref = "A1:G1"
for cell in ws[1]:
cell.font = Font(bold=True, color="FFFFFF")
cell.fill = PatternFill("solid", fgColor="245C78")
ws.column_dimensions[get_column_letter(int(cell.column or 1))].width = 32
ws.column_dimensions[get_column_letter(int(cell.column or 1))].width = (
65 if cell.column in {6, 7} else 28
)
help_sheet = wb.create_sheet("填写说明")
for line in [
"模板版本:1",
"每行一个独立任务;任务名称、任务目标必填。",
"预期结果决定要验证的业务条件。未填写时只报告任务完成情况。",
"前置条件描述实际用例起点;不会统一重启或清空应用数据。",
'输入参数使用 JSON 对象,例如 {"关键词":"天气"};目标可引用 {{关键词}}。',
"目标应用可填名称或包名;空白时由任务决定。",
"不要填写 Excel 公式、宏或外部链接。导入仅预览,点击开始后执行。",
]:
help_sheet.append([line])
help_sheet.column_dimensions["A"].width = 105
help_sheet.append(
["模板版本:2。任务表每行一项,Artemis提示词必填;只把这一列作为 AI 任务正文。"]
)
help_sheet.append(["前置约束提取及其他描述列仅作查看、搜索和报告元数据,不追加到提示词。"])
help_sheet.append(["任务配置(JSON)可留空,空字段继承批次或全局配置;禁止密钥与未知字段。"])
help_sheet.append(["示例仅供参考,不会导入执行。请在“任务”页填写自己的用例。"])
help_sheet.append(HEADERS)
help_sheet.append(
[
"示例-001",
"示例模块",
"示例检查",
"检查示例页面",
"设备已经打开目标应用",
"观察当前页面并报告实际结果,不要假设检查已通过。",
json.dumps(
{
"profile": "pro",
"model": "mimo-v2.6-flash",
"reasoning_effort": "high",
"verification_level": "final",
"record_video": True,
"analyze_video": False,
"request_timeout_seconds": 300,
"max_retries": 5,
"retry_interval_seconds": 10,
"timeout_seconds": 1800,
},
ensure_ascii=False,
),
]
)
for column in range(1, 8):
help_sheet.column_dimensions[get_column_letter(column)].width = 70 if column > 5 else 26
for row in help_sheet:
row[0].alignment = Alignment(wrap_text=True)
for cell in row:
cell.alignment = Alignment(wrap_text=True, vertical="top")
help_sheet.row_dimensions[6].height = 110
stream = BytesIO()
wb.save(stream)
return stream.getvalue()
@@ -90,7 +137,7 @@ def parse_workbook(data: bytes) -> dict:
rows = iter(wb["任务"].iter_rows())
headers = [str(cell.value or "").strip() for cell in next(rows, [])]
if headers != list(HEADERS):
raise ValueError("列名或顺序与模板不一致,请重新下载模板")
raise ValueError("列名或顺序与新版模板不一致,请重新下载模板")
tasks, errors = [], []
for number, row in enumerate(rows, 2):
if number > MAX_ROWS + 1:
@@ -100,28 +147,33 @@ def parse_workbook(data: bytes) -> dict:
try:
if any(cell.data_type == "f" for cell in row):
raise ValueError("不允许公式,请粘贴为纯文本")
values = [str(cell.value or "").strip() for cell in row[: len(HEADERS)]]
item = dict(zip(HEADERS.values(), values))
item["parameters"] = json.loads(item.get("parameters") or "{}")
case = TaskCase.model_validate(item)
values = [
str(cell.value if cell.value is not None else "").strip() for cell in row[:7]
]
config = json.loads(values[6] or "{}")
if not isinstance(config, dict):
raise ValueError("任务配置必须为 JSON 对象")
case = TaskCase.model_validate(
{
"case_id": values[0],
"category": values[1],
"subcategory": values[2],
"name": values[3] or values[2] or values[0] or values[5][:120],
"preconditions": values[4],
"goal": values[5],
"task_config": config,
}
)
tasks.append({"row": number, **case.model_dump()})
except (ValueError, TypeError) as exc:
errors.append({"row": number, "message": str(exc)[:500]})
if not tasks and not errors:
errors.append({"row": 2, "message": "没有任务,请填写模板后重新导入"})
return {"version": 1, "tasks": tasks, "errors": errors, "valid": bool(tasks) and not errors}
return {"version": 2, "tasks": tasks, "errors": errors, "valid": bool(tasks) and not errors}
finally:
wb.close()
def case_goal(case: dict) -> str:
goal = case["goal"]
for key, value in case.get("parameters", {}).items():
goal = goal.replace("{{" + key + "}}", str(value))
return (
f"任务名称:{case['name']}\n任务目标:{goal}\n目标应用:{case.get('app') or '按目标判断'}\n"
f"前置条件:{case.get('preconditions') or '根据实际用例判断合适起点'}\n"
"先观察手机并恢复到本用例需要的起始位置,记录准备过程。不要固定重启或清空数据。"
"如果无法建立前置条件,明确报告前置条件未满足,不继续正文操作。\n"
f"预期结果:{case.get('expected') or '仅确认任务完成情况,不宣称测试通过'}"
)
"""Execution gets the prompt verbatim; metadata stays outside model input."""
return case["goal"]
@@ -302,7 +302,7 @@ class TaskQueueService:
normalized = current_status.lower().strip() if isinstance(current_status, str) else None
if normalized == "success":
return "completed", True
if normalized in {"completed", "failed", "cancelled"}:
if normalized in {"completed", "failed", "cancelled", "interrupted"}:
return normalized, False
return ("completed" if returncode == 0 else "failed"), True
@@ -388,10 +388,9 @@ class TaskQueueService:
@classmethod
def _dispatch_pending_tasks(cls) -> None:
"""Launch every pending task admissible under the current concurrency limit."""
cls._sync_control_states()
limit = cls._concurrency_limit()
state.prune_finished_runs()
if limit == 1 and state.is_running:
return
# Dispatched-but-not-yet-spawned runs are only visible as queue items in
# "running" state, so admission must count those too -- active_runs alone
@@ -410,7 +409,9 @@ class TaskQueueService:
and item.get("pid") not in active_pids
for item in in_flight
)
capacity = limit - len(state.active_runs) - starting
capacity = (
limit - sum(not r.get("parked") for r in state.active_runs.values()) - starting
)
if capacity <= 0:
return
busy_devices = state.busy_device_ids | {
@@ -432,11 +433,15 @@ class TaskQueueService:
# A task without a resolved device may bind to any serial, so it
# only launches on an otherwise idle scheduler; the device lock
# then allocates freely without contending against active runs.
if device is None and (state.active_runs or in_flight or dispatched_any):
if device is None and (
any(not r.get("parked") for r in state.active_runs.values())
or in_flight
or dispatched_any
):
continue
if device is not None and target.lock_key in busy_devices:
continue
elif limit > 1 and device is not None and target.lock_key in busy_devices:
elif limit >= 1 and device is not None and target.lock_key in busy_devices:
# A second worker for this device would wait on its lock.
continue
@@ -454,6 +459,42 @@ class TaskQueueService:
if capacity <= 0:
break
@classmethod
def _sync_control_states(cls) -> None:
from artemis.runtime.task_control import read_state
from apps.admin_console.services.automation_store import store
for item in state.queue_items:
if not isinstance(item, dict):
continue
sid = str(item.get("session_id") or "")
run = state.active_runs.get(sid)
if not run:
continue
status = read_state(sid).get("status")
if status not in {"running", "task_paused", "resuming"}:
continue
old = item.get("status")
if status == old:
continue
parked = status in {"task_paused", "resuming"}
now = time.time()
if parked and not run.get("parked"):
run["non_execution_started_at"] = now
elif not parked and run.get("parked"):
run["non_execution_seconds"] = (
float(run.get("non_execution_seconds") or 0)
+ now
- float(run.pop("non_execution_started_at", now))
)
run["parked"] = parked
item["status"] = status
job = store.job(sid)
if job and job.get("status") != "finalizing":
job["status"] = status
store.save_job(job)
cls._broadcast_event("task_status", {"session_id": sid, "status": status})
@classmethod
def _begin_task_run(
cls,
@@ -531,6 +572,22 @@ class TaskQueueService:
env["ARTEMIS_SESSION_ID"] = str(sess_id)
env["ARTEMIS_TASK_INGRESS"] = str(task_item.get("ingress", "frontend"))
env["ARTEMIS_TASK_WORKER"] = "1"
overrides = task_item.get("runtime_overrides") or {}
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",
}.items():
if overrides.get(option) is not None:
env[variable] = (
str(overrides[option]).lower()
if isinstance(overrides[option], bool)
else str(overrides[option])
)
target.endpoint.apply_to_environment(env)
env[DeviceExecutionLock.LOCK_SCOPE_ENV] = target.lock_scope
queue_ticket = task_item.get("queue_ticket")
@@ -664,6 +721,12 @@ class TaskQueueService:
returncode,
manual_stop,
)
if (
state.is_shutting_down
and not manual_stop
and current_status not in {"completed", "success", "failed", "interrupted"}
):
new_status, should_persist = "interrupted", True
if should_persist:
if session_repo.update_session_status(sess_id, new_status, time.time()):
print(f"[QueueWorker] Updated session {sess_id} status to '{new_status}'")
@@ -1069,6 +1132,7 @@ class TaskQueueService:
conversation_id: str | None = None,
verification_level: str | None = None,
explorer_mode: str | None = None,
runtime_overrides: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Enqueues one or more goals and wakes up the background worker.
@@ -1123,6 +1187,8 @@ class TaskQueueService:
verification_level=verification_level,
explorer_mode=explorer_mode,
)
if runtime_overrides:
task_item["runtime_overrides"] = runtime_overrides
state.queue_items.append(task_item)
enqueued_tasks.append(task_item)
cls._broadcast_startup_progress(
@@ -13,7 +13,23 @@
<section><h3>当前任务报告</h3>
@if (report(); as r) {
<strong>{{r.name}}</strong><p>{{label(r.status)}} · {{r.verdict}}</p>
<div class="actions"><a [href]="'/api/automation/reports/'+r.id+'/docx'">下载 Word</a><a [href]="'/api/automation/reports/'+r.id+'/xlsx'">下载 Excel</a></div>
<div class="actions"><a [href]="'/api/automation/reports/'+r.id+'/docx'">下载 Word</a><a [href]="'/api/automation/reports/'+r.id+'/xlsx'">下载 Excel</a>
<a [href]="'/api/automation/reports/'+r.id+'/screenshots'">步骤截图 ZIP</a>
<a [href]="'/api/automation/reports/'+r.id+'/video'">分段录屏 ZIP</a>
<a [href]="'/api/automation/reports/'+r.id+'/all'">下载全部资料</a></div>
@if (liveTask()) {
<div class="task-guidance">
<h3>任务指导 · {{label(taskStatus())}}</h3>
<p>运行中提交将在下一步执行前生效;暂停后会释放设备,提交指导即可重新排队恢复。</p>
<label for="task-guidance-text">补充信息或调整方向</label>
<textarea id="task-guidance-text" [(ngModel)]="guidance" maxlength="10000" rows="3" placeholder="描述已观察到的情况和下一步方向"></textarea>
<div class="actions">
@if(taskStatus() === 'running'){<button [disabled]="busy()" (click)="pauseTask(r.id)">暂停当前任务</button>}
<button [disabled]="busy() || !guidance.trim()" (click)="guideTask(r.id)">提交指导{{taskStatus() === 'task_paused' ? '并恢复' : ''}}</button>
@if(taskStatus() === 'task_paused'){<button [disabled]="busy()" (click)="continueTask(r.id)">直接继续</button>}
</div>
</div>
}
<p class="muted">报告包含目标、预期、实际结论、步骤与截图证据。执行中的下载为当前快照。</p>
<pre class="preview">{{r.content}}</pre>
} @else { <p>从任务队列或历史记录中选择任务,即可下载报告。</p> }
@@ -23,12 +39,12 @@
<section class="device"><label>执行设备<select [(ngModel)]="device"><option value="">请选择设备</option>@for(d of system.connectedDevices(); track d.serial){<option [value]="d.serial">{{d.serial}}</option>}</select></label></section>
}
@if (tab() === 'batches') {
<section><h3>导入任务</h3><p>每行一个用例,填写名称与目标。预期结果、应用、前置条件、参数和标签可选。</p>
<section><h3>导入任务</h3><p>每行一个用例,仅「Artemis提示词」作为任务正文;「任务配置(JSON)」可设置每项模型、模式、超时和录屏。「前置约束提取」仅供查看与搜索。示例在模板的「填写说明」页。</p>
<div class="actions"><a href="/api/automation/template">下载 Excel 模板</a><label class="upload">导入 Excel<input type="file" accept=".xlsx" [disabled]="busy()" (change)="upload($event)"></label></div>
@if(preview(); as p) {
<p>有效任务 {{p.tasks.length}} 条 · 错误 {{p.errors.length}} 条</p>
@for(e of p.errors; track e.row){<p class="error">第 {{e.row}} 行:{{e.message}}</p>}
<div class="table-wrap"><table><thead><tr><th>行</th><th>名称</th><th>目标</th></tr></thead><tbody>@for(t of p.tasks; track t.row){<tr><td>{{t.row}}</td><td>{{t.name}}</td><td>{{t.goal}}</td></tr>}</tbody></table></div>
<div class="table-wrap"><table><thead><tr><th>行</th><th>用例</th><th>提示词</th><th>任务配置</th></tr></thead><tbody>@for(t of p.tasks; track t.row){<tr><td>{{t.row}}</td><td>{{t.name}}</td><td>{{t.goal}}</td><td>{{t.task_config | json}}</td></tr>}</tbody></table></div>
<label>批次名称<input [(ngModel)]="name" maxlength="200"></label>
<div class="fields"><label>执行模式<select [(ngModel)]="profile"><option value="flash">Flash</option><option value="pro">Pro</option></select></label><label>单任务时限(秒)<input type="number" min="30" max="86400" [(ngModel)]="timeout"></label></div>
<label><input type="checkbox" [(ngModel)]="stopOnFailure"> 失败即停止后续任务(默认继续)</label>
@@ -45,9 +61,9 @@
@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'){<button [disabled]="busy()" (click)="batchAction('retry_failed')">复制失败项为新批次</button>}
<a [href]="'/api/automation/batches/'+b.id+'/archive'">下载全部报告 ZIP</a>
<a [href]="'/api/automation/batches/'+b.id+'/archive'">下载批次报告 ZIP</a>
</div><p class="muted">服务重启后等待手动继续。失败项不会自动重试;暂停不打断当前任务。</p>
@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)">{{j.position}}. {{j.case.name}}</button><span>{{label(j.status)}}</span>@if(j.error){<small>{{j.error}}</small>}</div>}
@for(j of b.jobs; track j.session_id){<div class="job"><button (click)="agent.selectSession(j.session_id,true); tab.set('reports')">{{j.position}}. {{j.case.name}}</button><span>{{label(j.status)}}</span>@if(j.error){<small>{{j.error}}</small>}</div>}
</section>}
}
@@ -20,3 +20,5 @@ input[type=checkbox]{width:15px;height:15px;accent-color:#2563eb;vertical-align:
.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;}
.task-guidance{margin-top:16px;padding:14px;border:1px solid #bfdbfe;border-radius:10px;background:#f8fbff;}
.task-guidance label{margin:8px 0 4px;}
@@ -15,6 +15,9 @@ 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);
guidance = '';
taskStatus = signal('');
liveTask = signal(false);
word = true; excel = true; name = ''; device = ''; profile = 'flash'; stopOnFailure = false; timeout = 1800;
private destroyed = false;
constructor() {
@@ -24,12 +27,12 @@ export class AutomationPanelComponent {
const timer = setInterval(() => void this.refresh(), 5000);
destroy.onDestroy(() => { this.destroyed = true; clearInterval(timer); });
effect(() => {
const sid = this.agent.currentSessionId(); this.report.set(null);
const sid = this.agent.currentSessionId(); this.report.set(null); this.guidance = '';
if (sid) this.http.get<any>(`/api/automation/reports/${encodeURIComponent(sid)}`).subscribe({ next: r => { if (this.agent.currentSessionId() === sid) this.report.set(r); }, error: () => {} });
});
}
formats() { return [ ...(this.word ? ['docx'] : []), ...(this.excel ? ['xlsx'] : []) ]; }
label(s: string) { return ({draft:'待开始',running:'执行中',paused:'已暂停',completed:'执行完成',failed:'失败',cancelled:'已取消',pending:'待执行',queued:'排队中',interrupted:'中断',skipped:'跳过',finalizing:'正在生成报告'} as Record<string,string>)[s] || s; }
label(s: string) { return ({draft:'待开始',running:'执行中',paused:'已暂停',task_paused:'等待指导',resuming:'等待设备恢复',completed:'执行完成',failed:'失败',cancelled:'已取消',pending:'待执行',queued:'排队中',interrupted:'中断',skipped:'跳过',finalizing:'正在生成报告'} as Record<string,string>)[s] || s; }
async action(fn: () => Promise<void>) {
if (this.busy()) return;
this.busy.set(true); this.error.set(''); this.notice.set('');
@@ -43,6 +46,11 @@ export class AutomationPanelComponent {
this.batches.set(b);
const sid = this.agent.currentSessionId();
if (sid && this.tab() === 'reports') { const r = await firstValueFrom(this.http.get<any>(`/api/automation/reports/${encodeURIComponent(sid)}`)); if (this.agent.currentSessionId() === sid) this.report.set(r); }
if (sid) {
const current = this.agent.sessions().find(s => s.session_id === sid);
this.taskStatus.set(current?.status || this.report()?.status || '');
this.liveTask.set(['running','task_paused','resuming'].includes(current?.status || ''));
} else { this.taskStatus.set(''); this.liveTask.set(false); }
const id = this.batch()?.id;
if (id) { const item = await firstValueFrom(this.http.get<any>(`/api/automation/batches/${id}`)); if (this.batch()?.id === id) this.batch.set(item); }
} catch { /* User initiated actions expose errors; polling retries on the next interval. */ }
@@ -63,4 +71,14 @@ export class AutomationPanelComponent {
}); }
selectBatch(id: string) { void this.action(async () => { this.batch.set(await firstValueFrom(this.http.get(`/api/automation/batches/${id}`))); }); }
batchAction(action: string) { void this.action(async () => { this.batch.set(await firstValueFrom(this.http.post(`/api/automation/batches/${this.batch().id}/${action}`,{}))); await this.refresh(); }); }
pauseTask(id: string) { void this.action(async () => { await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/pause`,{})); this.notice.set('已请求暂停:当前步骤结束后释放设备'); await this.refresh(); }); }
guideTask(id: string) { void this.action(async () => {
if (!this.guidance.trim()) throw Error('请先填写指导内容');
await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/guide`,{instruction:this.guidance.trim()}));
this.guidance = ''; this.notice.set('指导已提交;运行中将在下一步生效,已暂停的任务会重新排队恢复'); await this.refresh();
}); }
continueTask(id: string) { void this.action(async () => {
await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/continue`,{}));
this.notice.set('任务已申请恢复;设备忙时等待排队'); await this.refresh();
}); }
}
@@ -5,6 +5,7 @@ import { finalize } from 'rxjs';
interface Capabilities {
video_enabled: boolean;
recording_enabled: boolean;
outputter_enabled: boolean;
step_summarizer_enabled: boolean;
}
@@ -16,7 +17,9 @@ interface Capabilities {
<h3>功能开关</h3>
<p>按需启用,保存后对新任务生效。</p>
<label><input type="checkbox" role="switch" [(ngModel)]="config.video_enabled" [disabled]="busy() || loading() || !loaded()" />
<span>视频分析与录屏<small>录制设备画面,并允许智能体分析录像;关闭可减少资源占用。</small></span></label>
<span>视频分析<small>允许智能体分析任务画面。运行期间会临时录制,完成后不保留录像(除非开启录屏)。</small></span></label>
<label><input type="checkbox" role="switch" [(ngModel)]="config.recording_enabled" [disabled]="busy() || loading() || !loaded()" />
<span>保存录屏<small>保留任务录像以便复盘,可单独开启,不要求启用视频分析。</small></span></label>
<label><input type="checkbox" role="switch" [(ngModel)]="config.outputter_enabled" [disabled]="busy() || loading() || !loaded()" />
<span>最终报告生成<small>根据执行记录生成任务结果报告。</small></span></label>
<label><input type="checkbox" role="switch" [(ngModel)]="config.step_summarizer_enabled" [disabled]="busy() || loading() || !loaded()" />
@@ -36,7 +39,7 @@ interface Capabilities {
export class CapabilitySettingsComponent implements OnInit {
changed = output<Capabilities>();
private http = inject(HttpClient);
config: Capabilities = { video_enabled: false, outputter_enabled: true, step_summarizer_enabled: true };
config: Capabilities = { video_enabled: false, recording_enabled: false, outputter_enabled: true, step_summarizer_enabled: true };
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({
@@ -98,21 +98,24 @@ describe('持久化模型与功能设置', () => {
http.expectNone('/api/system/capability-settings');
});
it('保存关闭视频分析的配置,重新打开时恢复关闭状态', () => {
const capabilities = { video_enabled: true, outputter_enabled: true, step_summarizer_enabled: false };
it('录屏与视频分析独立保存,重新打开后保留各自状态', () => {
const capabilities = { video_enabled: true, recording_enabled: false, outputter_enabled: true, step_summarizer_enabled: false };
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.save();
const request = http.expectOne('/api/system/capability-settings');
expect(request.request.body.video_enabled).toBeFalse();
const saved = { ...capabilities, video_enabled: false };
expect(request.request.body.recording_enabled).toBeTrue();
const saved = { ...capabilities, video_enabled: false, recording_enabled: true };
request.flush(saved);
fixture.destroy();
const reopened = TestBed.createComponent(CapabilitySettingsComponent);
reopened.detectChanges();
http.expectOne('/api/system/capability-settings').flush(saved);
expect(reopened.componentInstance.config.video_enabled).toBeFalse();
expect(reopened.componentInstance.config.recording_enabled).toBeTrue();
});
});
@@ -103,7 +103,9 @@ export class AgentService {
let sStatus = s.status;
if (!isTerminal) {
const isPending = !!pendingMatch && !isCurrentActive;
sStatus = isCurrentActive ? (status === 'paused' ? 'paused' : 'running') : (isPending ? 'pending' : s.status);
sStatus = isCurrentActive
? (activeMatch?.status || (status === 'paused' ? 'paused' : 'running'))
: (isPending ? 'pending' : s.status);
} else {
sStatus = (s.status === 'success') ? 'completed' : s.status;
}
@@ -138,7 +140,7 @@ export class AgentService {
|| !!activeMatch;
const finalPending: Session = {
...p,
status: isCurrentRunning ? (status === 'paused' ? 'paused' : 'running') : (p.status || 'pending'),
status: isCurrentRunning ? (activeMatch?.status || (status === 'paused' ? 'paused' : 'running')) : (p.status || 'pending'),
device_serial: p.device_serial || p.device_id || activeMatch?.device_id || null
};
sessionMap.set(p.session_id, finalPending);
@@ -156,7 +158,7 @@ export class AgentService {
session_id: sid,
initial_goal: at.goal || goal || '',
start_time: at.acquired_at ? (new Date(at.acquired_at).getTime() / 1000) : (Date.now() / 1000),
status: 'running',
status: at.status || 'running',
model_info: this.activeModel() || undefined,
device_serial: at.device_id || null
};
+4 -1
View File
@@ -37,6 +37,7 @@ from artemis.agents.explorer.tiers import SUBMIT_TOOL
from artemis.agents.explorer.tool_declarations import UNIVERSAL_EXPLORER_TOOLS
from artemis.graph.state import State
from artemis.services.llm import get_llm
from artemis.config.settings import settings
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
@@ -120,7 +121,9 @@ class UniversalRunnerMixin:
else:
current_llm = bound_llm
response = await asyncio.wait_for(current_llm.ainvoke(messages), timeout=180)
response = await asyncio.wait_for(
current_llm.ainvoke(messages), timeout=settings.OPENAI_TIMEOUT_SECONDS
)
messages.append(response)
tool_calls = getattr(response, "tool_calls", None) or []
+22 -1
View File
@@ -37,7 +37,6 @@ active window until its turn is chunked, whose user lines then carry it on.
"""
import asyncio
from artemis.config.settings import settings
import base64
from dataclasses import dataclass, field
import json
@@ -45,6 +44,9 @@ from pathlib import Path
import re
import uuid
from artemis.config.settings import settings
from artemis.runtime.task_control import TaskControl
from jinja2 import Template
from langchain_core.messages import (
BaseMessage,
@@ -1060,6 +1062,25 @@ class FlashRunner:
pending_notices: list[str] = []
while limit is None or turns < limit:
control = getattr(self.ctx, "task_control", None)
if isinstance(control, TaskControl):
resumed = await control.checkpoint(
on_pause=getattr(self.ctx, "on_task_pause", None),
on_resume=getattr(self.ctx, "on_task_resume", None),
)
if resumed:
(
shot_path,
current_pre_screenshot_bytes,
current_xml_list,
) = await capture_screenshot_and_parse_ui(
self.ctx, state, self.controller, skip_settling=False
)
state.latest_screenshot = shot_path
pending_notices.append(
"设备在暂停期间可能被其他任务操作过。已重新观察画面;"
"请根据当前状态和新指导重新判断操作,勿沿用旧坐标。"
)
turns += 1
logger.info(f"--- Artemis Flash Turn {turns}{f'/{limit}' if limit else ''} ---")
+1
View File
@@ -109,6 +109,7 @@ class Settings(BaseSettings):
ARTEMIS_CHECKPOINT_TIMEOUT_SECONDS: float = Field(default=180.0, gt=0)
ARTEMIS_SETTLEMENT_TIMEOUT_SECONDS: float = Field(default=120.0, gt=0)
ARTEMIS_VIDEO_ENABLED: bool = False
ARTEMIS_RECORDING_ENABLED: bool = False
ARTEMIS_OUTPUTTER_ENABLED: bool = True
ARTEMIS_STEP_SUMMARIZER_ENABLED: bool = True
# Consecutive Pro responses without a valid tool call before failing the task.
+2
View File
@@ -46,6 +46,7 @@ except ImportError:
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr
from artemis.utils.video import detect_video_tools_enabled
from artemis.config.settings import settings
from artemis.config import ExplorerConfig, LLMConfig, OutputterConfig
@@ -108,6 +109,7 @@ class ExecutionSetup(BaseModel):
enable_remote_tracing: bool = False
app_lock_status: AppLaunchResult | None = None
video_recording_tools_enabled: bool = Field(default_factory=detect_video_tools_enabled)
record_video_enabled: bool = Field(default_factory=lambda: settings.ARTEMIS_RECORDING_ENABLED)
disable_checker: bool = False
"""Legacy master switch (compat alias): ``True`` disables BOTH the midway
checkpoints and the final check, regardless of the individual gates below.
+14
View File
@@ -372,6 +372,20 @@ class StorageManager:
)
conn.commit()
def discard_session_recordings(self, session_id: str) -> None:
"""Remove references to temporary analysis-only captures after the run."""
with self._get_connection() as conn:
conn.execute(
"UPDATE video_recordings SET local_video_path = NULL, status = 'unavailable' "
"WHERE session_id = ?",
(session_id,),
)
conn.execute(
"UPDATE sessions SET video_filepath = NULL WHERE session_id = ?",
(session_id,),
)
conn.commit()
def update_session_video_path(self, session_id: UUID, video_path: str):
"""Update video filepath across session and video_recordings tables."""
with self._get_connection() as conn:
+10 -12
View File
@@ -23,6 +23,7 @@ from artemis.controllers.unified_controller import UnifiedMobileController
from artemis.data_engine.trace import trace
from artemis.graph.state import State
from artemis.graph.visibility import strict_state
from artemis.runtime.task_control import TaskControl
from artemis.utils.logger import get_logger
from artemis.utils.ocr_api import is_ocr_configured, perform_ocr
from artemis.utils.ocr_xml_fusion import (
@@ -78,18 +79,9 @@ def _check_injected_instruction_file(base_dir: str) -> dict | None:
Returns the payload dict: `instruction` (str | None) plus the explicit
`release_loop` stop signal (bool) declared by the injecting caller.
"""
instruction_path = Path(base_dir) / "injected_instruction.json"
if instruction_path.exists():
with open(instruction_path, encoding="utf-8") as f:
data = json.load(f)
instruction_path.unlink()
payload = {
"instruction": data.get("instruction"),
"release_loop": bool(data.get("release_loop")),
}
logger.info(f"Read and deleted injected instruction payload: {payload}")
return payload
return None
from artemis.runtime.task_control import consume_instructions
return consume_instructions(base_dir)
def _unprotect_current_check_lines(ctx: ArtemisContext) -> None:
@@ -122,6 +114,12 @@ async def perception_node(state: State, ctx: ArtemisContext) -> dict:
"""
state = strict_state(state, "perception")
logger.info("Entering Perception Node...")
control = getattr(ctx, "task_control", None)
if isinstance(control, TaskControl):
await control.checkpoint(
on_pause=getattr(ctx, "on_task_pause", None),
on_resume=getattr(ctx, "on_task_resume", None),
)
if not ctx.data_engine.current_step_id:
ctx.data_engine.allocate_step_id()
+156
View File
@@ -0,0 +1,156 @@
"""Cooperative, session-scoped task guidance and device lease parking."""
import asyncio
import json
import logging
import os
from pathlib import Path
import threading
import time
import uuid
from artemis.config import TRACES_PATH
from artemis.runtime.device_lock import DeviceExecutionLock
logger = logging.getLogger(__name__)
def control_dir(session_id: str) -> Path:
if not session_id or not all(c.isalnum() or c in "-_" for c in session_id):
raise ValueError("无效的任务编号")
return TRACES_PATH / session_id
def _write_atomic(path: Path, data: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
temporary.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
os.replace(temporary, path)
def read_state(session_id: str) -> dict:
try:
return json.loads(
(control_dir(session_id) / "task_control_state.json").read_text(encoding="utf-8")
)
except (OSError, ValueError):
return {}
def request_pause(session_id: str) -> None:
(control_dir(session_id) / "resume.request").unlink(missing_ok=True)
_write_atomic(control_dir(session_id) / "pause.request", {"created_at": time.time()})
def request_resume(session_id: str) -> None:
_write_atomic(control_dir(session_id) / "resume.request", {"created_at": time.time()})
def add_instruction(
session_id: str, instruction: str, *, release_loop: bool = False, source: str = "user"
) -> None:
if not instruction.strip() or len(instruction) > 10000:
raise ValueError("指导内容必须在 1–10000 字之间")
directory = control_dir(session_id) / "instructions"
directory.mkdir(parents=True, exist_ok=True)
# Unique names make simultaneous web and MCP submissions additive, never last-write-wins.
path = directory / f"{time.time_ns():020d}-{uuid.uuid4().hex}.json"
_write_atomic(
path,
{
"instruction": instruction.strip(),
"release_loop": bool(release_loop),
"source": source,
"timestamp": time.time(),
},
)
def consume_instructions(base_dir: str | Path) -> dict | None:
directory = Path(base_dir) / "instructions"
found = []
for path in sorted(directory.glob("*.json")) if directory.exists() else []:
try:
claimed = path.with_suffix(".reading")
path.rename(claimed)
found.append(json.loads(claimed.read_text(encoding="utf-8")))
claimed.unlink()
except (OSError, ValueError):
continue
legacy = Path(base_dir) / "injected_instruction.json"
if legacy.exists():
try:
claimed = legacy.with_suffix(".reading")
legacy.rename(claimed)
found.append(json.loads(claimed.read_text(encoding="utf-8")))
claimed.unlink()
except (OSError, ValueError):
pass
if not found:
return None
return {
"instruction": "\n\n".join(str(item.get("instruction") or "") for item in found).strip(),
"release_loop": any(bool(item.get("release_loop")) for item in found),
}
class TaskControl:
"""Yield a device lease at the next safe turn and reacquire at the FIFO tail."""
def __init__(self, session_id: str, device_lock: DeviceExecutionLock):
self.session_id = session_id
self.device_lock = device_lock
self.directory = control_dir(session_id)
def _state(self, status: str) -> None:
_write_atomic(
self.directory / "task_control_state.json",
{"status": status, "updated_at": time.time(), "pid": os.getpid()},
)
async def checkpoint(self, *, on_pause=None, on_resume=None) -> bool:
pause = self.directory / "pause.request"
if not pause.exists():
return False
pause.unlink(missing_ok=True)
if on_pause is not None:
await on_pause()
self.device_lock.release()
self._state("task_paused")
resume = self.directory / "resume.request"
while not resume.exists():
await asyncio.sleep(0.2)
resume.unlink(missing_ok=True)
self._state("resuming")
previous = self.device_lock
self.device_lock = DeviceExecutionLock(
previous.device_id,
description=previous.description,
max_concurrency=previous.max_concurrency,
concurrency_mode=previous.concurrency_mode,
session_id=self.session_id,
ingress=previous.ingress,
lock_scope=previous.lock_scope,
)
cancelled = threading.Event()
acquiring = asyncio.create_task(
asyncio.to_thread(self.device_lock.acquire, cancel_event=cancelled)
)
try:
await asyncio.shield(acquiring)
except asyncio.CancelledError:
cancelled.set()
try:
await asyncio.shield(acquiring)
except Exception:
logger.warning("Resume acquisition ended during cancellation", exc_info=True)
self.device_lock.release()
raise
if on_resume is not None:
await on_resume()
self._state("running")
return True
def release(self) -> None:
self.device_lock.release()
(self.directory / "task_control_state.json").unlink(missing_ok=True)
+62 -4
View File
@@ -540,6 +540,7 @@ class Agent:
last_state: State | None = None
last_state_snapshot: dict | None = None
output = None
task_control = None
effective_mode = getattr(self._config, "concurrency_mode", "per_device")
effective_max = getattr(self._config, "max_concurrency", None)
sess_id = (
@@ -599,6 +600,11 @@ class Agent:
# publish a new active session while the current task is still
# finishing.
self._prepare_tracing(task=task, context=context)
if device_lock is not None and os.environ.get("ARTEMIS_TASK_WORKER") == "1":
from artemis.runtime.task_control import TaskControl
task_control = TaskControl(str(sess_id), device_lock)
context.task_control = task_control
self._prepare_output_files(task=task)
if os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
if self._ui_adb_client is not None:
@@ -612,16 +618,46 @@ class Agent:
await self._prepare_app_lock(task=task, context=context)
async with context:
recording_started = False
if self._config.video_recording_tools_enabled:
try:
recording_parts = 1
controller = get_controller(context)
output_dir = None
if context.execution_setup and context.execution_setup.traces_path:
output_dir = (
self._tmp_traces_dir / context.execution_setup.trace_name
).resolve()
async def pause_capture():
nonlocal recording_started
if recording_started:
try:
await controller.stop_video_recording()
finally:
recording_started = False
from artemis.tools.command_tool import shutdown_adb_background_tasks
await shutdown_adb_background_tasks(context)
async def resume_capture():
nonlocal recording_started, recording_parts
if not (
self._config.video_recording_tools_enabled
or self._config.record_video_enabled
):
return
recording_parts += 1
target = (
output_dir / f"resume_{recording_parts:04d}" if output_dir else None
)
result = await controller.start_video_recording(output_dir=target)
recording_started = bool(result and result.success)
context.on_task_pause = pause_capture
context.on_task_resume = resume_capture
if (
self._config.video_recording_tools_enabled
or self._config.record_video_enabled
):
try:
logger.info(f"[{task_name}] Starting automated screen recording...")
start_res = await controller.start_video_recording(
output_dir=output_dir
@@ -840,6 +876,25 @@ class Agent:
except Exception as e:
logger.warning(f"[{task_name}] Failed to stop background ADB tasks: {e}")
try:
if (
self._config.video_recording_tools_enabled
and not self._config.record_video_enabled
):
# Video analysis used a temporary capture during execution.
# Keep no recording after the final model/report step.
trace_dir = (self._tmp_traces_dir / task_name).resolve()
root = self._tmp_traces_dir.resolve()
if trace_dir.is_relative_to(root) and trace_dir.is_dir():
for recording in trace_dir.rglob("*"):
if recording.is_file() and (
recording.suffix.lower() in {".mkv", ".mp4", ".webm"}
or recording.name == "recording.json"
):
recording.unlink(missing_ok=True)
if context.data_engine and context.data_engine.current_session_id:
context.data_engine.storage.discard_session_recordings(
str(context.data_engine.current_session_id)
)
await self._finalize_tracing_safely(task=task, context=context)
finally:
if os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
@@ -847,7 +902,9 @@ class Agent:
if self._ui_adb_client is not None:
await asyncio.to_thread(self._ui_adb_client.disconnect)
finally:
if device_lock is not None:
if task_control is not None:
await asyncio.to_thread(task_control.release)
elif device_lock is not None:
await asyncio.to_thread(device_lock.release)
async with self._task_lock:
@@ -1074,6 +1131,7 @@ class Agent:
if task.request.record_trace
else False,
video_recording_tools_enabled=self._config.video_recording_tools_enabled,
record_video_enabled=self._config.record_video_enabled,
disable_checker=self._config.disable_checker,
disable_midway_checks=self._config.disable_midway_checks,
disable_final_check=self._config.disable_final_check,
@@ -609,6 +609,7 @@ class AgentConfigBuilder:
servers=self._servers,
graph_config_callbacks=self._graph_config_callbacks,
video_recording_tools_enabled=self._video_recording_tools_enabled,
record_video_enabled=settings.ARTEMIS_RECORDING_ENABLED,
force_web_accessibility=self._force_web_accessibility,
disable_checker=self._disable_checker,
disable_midway_checks=self._disable_midway_checks,
+2
View File
@@ -29,6 +29,7 @@ from artemis.config import (
from artemis.context import DevicePlatform
from artemis.sdk.types.task import AgentProfile, TaskRequestCommon
from artemis.utils.video import detect_video_tools_enabled
from artemis.config.settings import settings
from pydantic import BaseModel, Field
@@ -113,6 +114,7 @@ class AgentConfig(BaseModel):
servers: ServerConfig
graph_config_callbacks: Callbacks = None
video_recording_tools_enabled: bool = Field(default_factory=detect_video_tools_enabled)
record_video_enabled: bool = Field(default_factory=lambda: settings.ARTEMIS_RECORDING_ENABLED)
force_web_accessibility: bool = False
disable_checker: bool = False
disable_midway_checks: bool = True
+2 -1
View File
@@ -6,7 +6,8 @@
"python_download_mirror": "https://mirrors.nju.edu.cn/github-release/astral-sh/python-build-standalone",
"npm_registry": "https://registry.npmmirror.com",
"node_mirror": "https://registry.npmmirror.com/-/binary/node",
"adb_url": "https://mirrors.cloud.tencent.com/AndroidSDK/platform-tools-latest-windows.zip"
"adb_url": "https://mirrors.cloud.tencent.com/AndroidSDK/platform-tools-latest-windows.zip",
"scrcpy_url": "https://mirrors.sdu.edu.cn/github-release/Genymobile_scrcpy/v4.1/scrcpy-win64-v4.1.zip"
},
"overrides": {
"python_index": "",
+3 -3
View File
@@ -22,13 +22,13 @@
| `mobile_workspace` | 设备列表与统一队列状态 |
| `mobile_config` | 读取或明确保存非敏感模型、功能、报告默认设置 |
| `mobile_run_task` | 单任务执行;`model=flash/pro` 表示执行模式 |
| `mobile_manage_task` | 状态、指导和停止单任务 |
| `mobile_manage_task` | 单任务状态、安全暂停、排队恢复、下一步指导和停止 |
| `mobile_get_device_state` | 设备截图、界面层级 |
| `mobile_task_file` | XLSX 模板与导入校验 |
| `mobile_batch` | JSON 用例建批次、开始、暂停、继续、复制失败项 |
| `mobile_search_history` | 全库搜索、日期/状态筛选和分页 |
| `mobile_inspect_trace` | 只读步骤、截图和执行证据 |
| `mobile_export_report` | Word、Excel 和批次 ZIP,返回本地绝对路径 |
| `mobile_export_report` | Word、Excel、步骤截图、录屏片段、单任务资料及批次 ZIP,返回本地绝对路径 |
新增工具通过统一后台服务运行,与网页共享配置、队列、历史和报告;不另外启动独立调度器。`ARTEMIS_STANDALONE` 模式不支持这些工作区工具。
@@ -42,6 +42,6 @@ MCP 自动刷新只处理已存在且指向当前项目的标准 Python 模块
报告导出到 `artifacts/mcp_exports/`,包含本地路径及服务下载链接。运行中导出是快照;执行完成不等于验证通过,报告会保留未验证、失败、跳过与中断状态。
模型默认使用 `mimo-v2.6-flash`,也预设 `gpt-5.6-luna` 并支持自定义。思考强度最高支持 `max`,默认 `high`,选“由模型默认”不发送该参数;接口不支持时明确报错。单次请求 300 秒,首次失败后最多再试 5 次,每次等待 10 秒,SDK 不叠加重试。耗尽后沿用任务暂停/恢复机制,鉴权或非法参数立即失败。视频分析与录屏默认关闭。
模型默认使用 `mimo-v2.6-flash`,也预设 `gpt-5.6-luna` 并支持自定义。思考强度最高支持 `max`,默认 `high`,选“由模型默认”不发送该参数;接口不支持时明确报错。单次请求 300 秒,首次失败后最多再试 5 次,每次等待 10 秒,SDK 不叠加重试。耗尽后沿用任务暂停/恢复机制,鉴权或非法参数立即失败。视频分析与录屏是独立开关,默认均关闭。每项批量用例可覆盖模式、模型、设备、录屏和超时等配置,不影响其他任务。暂停任务释放设备,AI 可用 `mobile_manage_task(action="inject_instruction")` 提交指导并恢复,或用 `resume` 单独恢复。
保存全局配置要求用户明确指令;只读配置不包含密钥,MCP 不接收密钥。密钥仍在网页或 `.env` 中填写。设置对新任务生效,模型请求重试不会触发测试用例自动重跑。历史步骤证据保留,设备动作回放已移除。
+6 -6
View File
@@ -4,7 +4,7 @@
## 导出结论报告
选择已有任务,在“结论报告”中下载 Word 或 Excel。默认同时生成两种格式;勾选所需格式并保存,后续提交使用新的默认值。
选择已有任务,在“报告与质量”中下载 Word、Excel、按步骤排序的截图 ZIP、录屏分段 ZIP,或一键下载包含上述文件和清单的资料 ZIP。只有启用并成功保存录屏的任务才有录屏可下载。默认报告格式为 Word 和 Excel;勾选所需格式并保存,后续提交使用新的默认值。
报告包含目标、预期结果、执行状态、验证结论、实际输出和步骤证据。Word 内嵌关键截图,Excel 包含结果与证据工作表;任务输出中的 Markdown 表格会转换为真正的文档表格和 Excel 工作表。
@@ -15,16 +15,16 @@
## Excel 批量导入
1. 点击“下载 Excel 模板”,保留“任务”工作表及表头顺序。
2. 每行填写一个独立用例。必填“任务名称”“任务目标”;其余列是预期结果、目标应用、前置条件、输入参数(JSON)、标签。
3. 参数示例为 `{"关键词":"天气"}`,在任务目标中使用 `{{关键词}}`。不要填写公式、宏或外部链接。
2. 标准列按顺序为“用例编号”“检测项目”“测试子项”“测试用例”“前置约束提取”“Artemis提示词”“任务配置(JSON)”。任务表留空,示例行在“填写说明”页。
3. 每行的“Artemis提示词”必填,且是唯一提交给 AI 的任务正文;其他文本列用于显示、搜索和报告,不自动拼入提示词。任务配置可留空,或填写 JSON 对象,如 `{"profile":"pro","model":"mimo-v2.6-flash","reasoning_effort":"high","record_video":true,"analyze_video":false,"request_timeout_seconds":300}`。支持单项覆盖设备、验证级别、观察模式、目标应用包名、重试及单任务时限;未设置字段继承批次或全局设置。不接受密钥和未知字段。不要填写公式、宏或外部链接。
4. 上传文件,查看逐行校验结果。所有错误修正后才能提交。
5. 明确选择设备、执行模式和超时时间,然后点击“确认导入并开始执行”。
同一批次按顺序执行。默认失败后继续且不自动重试,也可以选择失败即停止。设备不可用、模型暂停或任务超时会暂停后续派发。暂停批次让当前任务继续;全局停止会暂停批次并停止当前执行。
同一批次按顺序派发。默认失败后继续且不自动重试,也可以选择失败即停止。设备不可用、模型暂停或任务超时会暂停后续派发。暂停批次只停止后续派发。单个任务可在步骤间安全暂停,释放设备;批次下一项可继续,暂停的任务待提交指导或点击继续后在设备队列末尾恢复。运行中提交指导在下一步执行前生效,不打断当前步骤。暂停等待和重新排队时间不计入该任务执行时限。
服务重启后保留批次,等待用户继续未开始的用例。中断任务不自动重做;“复制失败项为新批次”保留原记录并产生新任务编号。
服务重启后保留批次,等待用户继续未开始的用例。正在执行或暂停的任务标记中断,不自动重做;“复制失败项为新批次”保留原记录并产生新任务编号。
每个任务结束后生成所选格式报告。批次结束后生成汇总及每项任务报告的 ZIP;失败、跳过和中断任务也会列入。暂停时下载的是当前快照。
每个任务结束后生成所选格式报告。批次结束后生成汇总及每项任务报告的 ZIP;失败、跳过和中断任务也会列入。暂停时下载的是当前快照。录屏与 AI 视频分析分别配置,录屏默认关闭;仅开启分析时,录制内容用于本次分析,结束后不保留下载文件。暂停期间不录制其他任务的设备画面。
## AI 使用
+18 -7
View File
@@ -184,7 +184,10 @@ async def mobile_batch(
"""Create, execute and manage durable batches on the unified device queue.
create requires a unique request_id and batch={name,device_serial,cases:[{name,goal,
expected?,app?,preconditions?,parameters?:{key:primitive},tags?}],profile?:flash|pro,
case_id?,category?,subcategory?,preconditions?,task_config?:{profile?,model?,
reasoning_effort?,device_serial?,verification_level?,explorer_mode?,
locked_app_package?,record_video?,analyze_video?,request_timeout_seconds?,
max_retries?,retry_interval_seconds?,timeout_seconds?}}],profile?:flash|pro,
formats?:[docx,xlsx],stop_on_failure?:false,timeout_seconds?:1800}. Required device
must be user-selected. Creation validates and saves a DRAFT; start executes it.
Reuse the same request_id and exact payload after a connection failure; never invent
@@ -225,7 +228,7 @@ async def mobile_search_history(
page: int = 1,
size: int = 30,
) -> dict:
"""Search ALL persisted terminal tasks by name, goal, ID or tags, with pagination.
"""Search ALL persisted terminal tasks by name, prompt, ID or case metadata, with pagination.
start/end are Unix seconds, inclusive/exclusive; convert the user's local dates
before calling. Status: completed/success/failed/cancelled/interrupted/skipped.
@@ -243,11 +246,12 @@ async def mobile_search_history(
async def mobile_export_report(
session_id: str | None = None,
batch_id: str | None = None,
formats: list[Literal["docx", "xlsx"]] | None = None,
formats: list[Literal["docx", "xlsx", "screenshots", "video", "all"]] | None = None,
) -> dict:
"""Export a task's Word/Excel reports or a batch ZIP to local files on the MCP host.
"""Export Word/Excel, ordered screenshots, saved recording segments or all task material.
Provide exactly one of session_id/batch_id. Task formats omitted uses saved defaults;
screenshots/video/all produce ZIPs; video requires recording to have been enabled.
batch ZIP uses its frozen formats and includes summary + individual case reports.
Returns absolute paths, download URLs and sizes; task results also include structured
verdict/evidence. Running exports are snapshots. 'completed' is not proof of PASS;
@@ -269,11 +273,18 @@ async def mobile_export_report(
if formats is not None
else (await _request("GET", "/api/automation/preferences"))["formats"]
)
if not chosen or len(chosen) > 2 or any(fmt not in {"docx", "xlsx"} for fmt in chosen):
raise ValueError("formats 至少选择 docx 或 xlsx,最多两项")
if (
not chosen
or len(chosen) > 5
or any(fmt not in {"docx", "xlsx", "screenshots", "video", "all"} for fmt in chosen)
):
raise ValueError("formats 需选择 docx、xlsx、screenshots、video 或 all")
report = await _request("GET", f"/api/automation/reports/{sid}")
files = [
await _download(f"/api/automation/reports/{sid}/{fmt}", f"task-{sid}.{fmt}")
await _download(
f"/api/automation/reports/{sid}/{fmt}",
f"task-{sid}.{fmt}" if fmt in {"docx", "xlsx"} else f"task-{sid}-{fmt}.zip",
)
for fmt in dict.fromkeys(chosen)
]
return {"report": report, "files": files}
+90 -25
View File
@@ -34,6 +34,13 @@ from artemis.runtime import (
trace_store,
)
from artemis.runtime.process_probe import pid_is_alive
from artemis.runtime.task_control import (
add_instruction,
control_dir,
read_state,
request_pause,
request_resume,
)
logger = logging.getLogger(__name__)
@@ -51,6 +58,34 @@ def _find_data_engine_db() -> str | None:
return db_path if os.path.exists(db_path) else None
def _web_session_status(trace_id: str) -> dict[str, Any] | None:
"""Web submissions have a session row but no MCP launcher status.json."""
db_path = _find_data_engine_db()
if not db_path:
return None
try:
with sqlite3.connect(db_path) as conn:
conn.row_factory = sqlite3.Row
row = conn.execute(
"SELECT status, pid, start_time, end_time, device_info "
"FROM sessions WHERE session_id = ? ORDER BY start_time DESC LIMIT 1",
(trace_id,),
).fetchone()
except sqlite3.Error as exc:
logger.warning("Could not read web session %s: %s", trace_id, exc)
return None
if not row:
return None
data = dict(row)
if data.get("device_info"):
try:
device = json.loads(data["device_info"])
data["device_serial"] = device.get("device_id") if isinstance(device, dict) else None
except (TypeError, ValueError):
pass
return data
def _pid_alive(pid: int | None) -> bool:
if not pid:
return False
@@ -143,8 +178,8 @@ def _reconcile_task_state(
liveness_failed = (
current_status == "failed" and status_data.get("error") == _LIVENESS_FAILURE_ERROR
)
if db_status in ("completed", "success", "failed", "cancelled") and (
current_status in ("running", "pending") or liveness_failed
if db_status in ("completed", "success", "failed", "cancelled", "interrupted") and (
current_status in ("running", "pending", "task_paused", "resuming") or liveness_failed
):
canonical = "completed" if db_status in ("completed", "success") else db_status
if current_status != canonical:
@@ -156,7 +191,7 @@ def _reconcile_task_state(
status_data["end_time"] = time.time()
dirty = True
is_alive = False
elif current_status in ("running", "pending"):
elif current_status in ("running", "pending", "task_paused", "resuming"):
if not pid:
# No pid recorded (daemon dispatch never writes one): liveness cannot be
# inferred, so the task is assumed alive -- matching the historical
@@ -225,13 +260,13 @@ def mobile_manage_task(
"""Manages the lifecycle and retrieves the status of a background mobile automation task.
This is your primary diagnostic and control tool for tasks started by
`mobile_run_task`. You MUST use it to poll the task status when your
`mobile_run_task` or the web workspace. You MUST use it to poll the task status when your
1-minute fallback timer triggers (as required by `mobile_run_task`). You
can also steer a subagent that is stuck or off-track, or abort a task.
### Actions
- **'status'**: Returns `trace_id`, `status` ('running'/'completed'/'failed'/
'cancelled'), `device_serial` (which phone owns this task in multi-device
- **'status'**: Returns `trace_id`, `status` ('running'/'task_paused'/
'resuming'/'completed'/'failed'/'cancelled'/'interrupted'), `device_serial` (which phone owns this task in multi-device
setups), `task_desc`, `model`, `elapsed_seconds`, a `test_summary` (when
the run declared verification check items: machine-readable
passed/failed/inconclusive/unchecked counts plus failed-item details —
@@ -239,6 +274,9 @@ def mobile_manage_task(
current turn, latest thought/action — intervene if turns climb without
progress or the thought indicates it is stuck; Pro: the active task plan
— check it still aligns with your goal).
- **'pause'**: Requests a pause after the current action, releasing the device.
- **'resume'**: Queues a paused task for its device again; optional `instruction`
provides guidance before the next action.
- **'inject_instruction'**: Injects real-time guidance mid-flight when the
subagent errs, stalls, or loops. Applied at the start of the next
planning turn (Pro) or the next reactive loop (Flash). Requires
@@ -254,15 +292,15 @@ def mobile_manage_task(
should wind down cleanly instead of being killed.
Args:
action: `"status"`, `"inject_instruction"`, or `"stop"`.
trace_id: The task's session identifier from `mobile_run_task`.
action: `"status"`, `"pause"`, `"resume"`, `"inject_instruction"`, or `"stop"`.
trace_id: The task's session identifier from `mobile_run_task` or the web UI.
instruction: Guidance string; required for `inject_instruction`
(optional when `release_loop=True`), omit otherwise.
release_loop: With `inject_instruction`: explicit user stop signal
that authorizes the subagent to complete continuous monitoring
loops and finish the task gracefully.
"""
status_data = trace_store.read_status(trace_id)
status_data = trace_store.read_status(trace_id) or _web_session_status(trace_id)
if not status_data:
return {
"trace_id": trace_id,
@@ -271,6 +309,9 @@ def mobile_manage_task(
}
current_status, pid, is_alive = _reconcile_task_state(trace_id, status_data)
control_status = read_state(trace_id).get("status") if is_alive else None
if control_status in ("task_paused", "resuming") and current_status in ("running", "pending"):
current_status = control_status
trace_dir = trace_store.get_trace_dir(trace_id)
@@ -368,7 +409,7 @@ def mobile_manage_task(
pass
break
if current_status in ("running", "pending") and is_alive:
if current_status in ("running", "pending", "task_paused", "resuming") and is_alive:
progress: dict[str, Any] = {}
project_root = env_utils.get_project_root()
@@ -483,8 +524,38 @@ def mobile_manage_task(
return response
elif action == "pause":
if current_status not in ("running", "pending", "resuming") or not is_alive:
return {
"trace_id": trace_id,
"status": current_status,
"message": "Task is not running.",
}
request_pause(trace_id)
return {
"trace_id": trace_id,
"status": "pausing",
"message": "Pauses after the current action and releases the device.",
}
elif action == "resume":
if current_status != "task_paused" or not is_alive:
return {
"trace_id": trace_id,
"status": current_status,
"message": "Task is not paused.",
}
if instruction:
add_instruction(trace_id, instruction, source="mcp")
request_resume(trace_id)
return {
"trace_id": trace_id,
"status": "resuming",
"message": "Queued for the device again.",
}
elif action == "stop":
if current_status not in ("running", "pending"):
if current_status not in ("running", "pending", "task_paused", "resuming"):
return {
"trace_id": trace_id,
"status": current_status,
@@ -579,7 +650,7 @@ def mobile_manage_task(
"message": "Missing required argument 'instruction' for action 'inject_instruction'.",
}
if current_status not in ("running", "pending"):
if current_status not in ("running", "pending", "task_paused", "resuming"):
return {
"trace_id": trace_id,
"status": current_status,
@@ -593,19 +664,13 @@ def mobile_manage_task(
"message": f"Trace directory not found: {trace_dir}",
}
instruction_path = os.path.join(trace_dir, "injected_instruction.json")
try:
with open(instruction_path, "w", encoding="utf-8") as f:
json.dump(
{
"instruction": instruction,
"release_loop": bool(release_loop),
"timestamp": time.time(),
"status": "pending",
},
f,
indent=2,
)
add_instruction(trace_id, instruction, release_loop=release_loop, source="mcp")
if (
current_status == "task_paused"
or (control_dir(trace_id) / "pause.request").exists()
):
request_resume(trace_id)
suffix = " (with release_loop stop signal)" if release_loop else ""
return {
"trace_id": trace_id,
@@ -623,5 +688,5 @@ def mobile_manage_task(
return {
"trace_id": trace_id,
"status": current_status,
"message": f"Invalid action '{action}'. Supported actions are 'status', 'stop', and 'inject_instruction'.",
"message": f"Invalid action '{action}'. Supported actions are 'status', 'pause', 'resume', 'stop', and 'inject_instruction'.",
}
+2
View File
@@ -39,6 +39,8 @@ function Initialize-ArtemisDownloads {
$env:npm_config_fetch_timeout = [string]($timeout * 1000)
$values.mode = $Mode
$values.timeout_seconds = $timeout
# Official scrcpy v4.1 win64 release digest. Verify every mirror/override equally.
$values.scrcpy_sha256 = '5b12172b3264b2889f4583ee64752ce832e29bc8b1089dca81093459697165db'
$script:ArtemisDownloads = [pscustomobject]$values
Write-Host " [INFO] Download mode: $Mode (config/downloads.json)." -ForegroundColor Cyan
}
+6 -1
View File
@@ -219,11 +219,16 @@ function Install-PortableScrcpy {
Write-Host " [INFO] Installing portable scrcpy in user space..." -ForegroundColor Cyan
if ($ArtemisDownloads.mode -eq 'China' -and $ArtemisDownloads.scrcpy_url.StartsWith('https://github.com/')) {
Write-Host ' [INFO] scrcpy uses GitHub. If unreachable, set overrides.scrcpy_url in config/downloads.json.' -ForegroundColor Yellow
Write-Host ' [WARN] China mode has no non-GitHub scrcpy source. Configure overrides.scrcpy_url or install scrcpy manually.' -ForegroundColor Yellow
return $false
}
try {
$zipPath = "$env:TEMP\scrcpy-win64.zip"
if (Invoke-DownloadFile -Uri $ArtemisDownloads.scrcpy_url -OutFile $zipPath) {
$actualHash = (Get-FileHash -LiteralPath $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actualHash -ne $ArtemisDownloads.scrcpy_sha256) {
throw 'Downloaded scrcpy ZIP failed official SHA256 verification.'
}
$extractDir = "$env:TEMP\scrcpy_extract"
if (Test-Path $extractDir) { Remove-Item $extractDir -Recurse -Force -ErrorAction SilentlyContinue }
New-Item -ItemType Directory -Path $extractDir -Force | Out-Null
+5 -5
View File
@@ -13,13 +13,13 @@ description: 使用 ARTEMIS MCP 在 Android 真机或模拟器上完成自然语
2. 用 `mobile_workspace` 查看设备和队列。沿用用户已指定的设备;多台可用设备且没有选择时先询问,不擅自挑选实体手机。任务参数显式传入 `device_serial`。同设备串行,不同设备可并行;本 Skill 不要求创建其他 AI 子代理。
3. 首次使用、工具出错、任务拒绝或设备异常,先 `mobile_diagnose`。按 `next_steps` 的依赖顺序处理;`attempt_fix=true` 可尝试工具自带修复。只有凭据相关故障才使用 `verify_credentials=true`,观察异常才使用 `probe_device=true`。不要盲目重发任务。MCP 不可用时在项目目录运行 `uv run artemis doctor` 并检查宿主 MCP 日志。
4. 选择执行模式:Flash 适合路径明确的界面操作;Pro 适合动态分支、持续计划、ADB/日志诊断或需要 Checker 明确校验的任务。**`mobile_run_task.model` 的 `flash`/`pro` 是执行模式,不是 API 模型名称**。步数长不自动等于需要 Pro;视频能力只有显式启用后才可用。
5. 单任务用 `mobile_run_task`;批量任务用 `mobile_batch`,读 [批量任务与交付](references/batches.md)。AI 可直接提交结构化用例,不必先创建 Excel。用户提供 Excel 时先 `mobile_task_file(action="preview", path=绝对路径)`,修正所有行错误后提交。
6. 单任务返回 `trace_id` 后,用 `mobile_manage_task(action="status", trace_id=...)` 跟踪;批次用 `mobile_batch(action="get", batch_id=...)`。尊重工具建议的等待周期,等待期间可以整理已得证据,避免连续高频轮询。停滞或失败先诊断;不把同一业务任务自动重新提交来解决模型错误。
5. 单任务用 `mobile_run_task`;批量任务用 `mobile_batch`,读 [批量任务与交付](references/batches.md)。AI 可直接提交结构化用例,不必先创建 Excel。用户提供 Excel 时先 `mobile_task_file(action="preview", path=绝对路径)`,修正所有行错误后提交。提示词列原样作为目标,前置约束列只作元数据。
6. 单任务返回 `trace_id` 后,用 `mobile_manage_task(action="status", trace_id=...)` 跟踪;网页任务也可用其任务编号。批次用 `mobile_batch(action="get", batch_id=...)`。需要人工补充时用 `pause` 在当前步骤后释放设备;`inject_instruction` 可运行中指导下一步,暂停中提交则恢复。无新指导用 `resume`。恢复进入设备 FIFO 队尾,并重新观察手机状态。尊重工具建议的等待周期,避免连续高频轮询;停滞或失败先诊断,不把同一业务任务自动重提。
## 结果、证据与交付
- 执行状态与业务判定分开:`completed` 只说明执行结束,不等于测试通过。检查工具返回的验证摘要、预期/实际结果,以及 `mobile_inspect_trace` 的步骤/截图。证据不足标记“未验证”;前置条件不满足说明阻塞原因,不能编造通过结论。
- 用 `mobile_export_report(session_id=...)` 获取单任务报告;批次传 `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 术语不表示重新执行设备动作。
@@ -30,8 +30,8 @@ description: 使用 ARTEMIS MCP 在 Android 真机或模拟器上完成自然语
- 模型名称支持预设 `gpt-5.6-luna`、`mimo-v2.6-flash` 和自定义名称,取决于用户配置的 Responses 网关。思考强度最高支持 `max`,默认 `high`、单次请求 300 秒、失败后最多重试 5 次、固定间隔 10 秒;默认关闭视频分析与录屏。不根据模型名称猜测网关能力。
- 强度选 `default` 表示不发送该参数,和 `none`(显式要求关闭)不同。接口拒绝强度参数时明确呈现失败,先询问用户是否改用 `default`,不能静默降级或换模型。
- API Key 只通过网页或 `.env` 配置。不要请求用户把 key 发到聊天中,不读取/回显已有 key。保存后新任务生效;直接编辑环境文件后需重启相应服务,运行中的任务保留自己的配置。
- 当前视频开关仅提供保存的全局设置,没有单任务覆盖或独立视频文件分析接口。用户只要求临时分析且默认关闭时,明确说明这个限制;不能用“先开全局、执行后再关”影响同期任务。只有用户改为要求保存启用后,才修改配置并提交新任务。
- 录屏与 AI 视频分析分别配置。批量任务的 `task_config` 可覆盖单项,不修改全局设置;只开视频分析时录制内容不作为可下载文件保存。暂停时停止录屏,恢复后按片段继续,不能把其他任务画面混入该任务资料。
- 请求重试是模型调用恢复,不是测试用例重跑。批次默认失败继续、不自动重跑;服务重启后保留状态并暂停,用户明确要求后才继续或复制失败项。
- 批次暂停只停止后续派发;要停止当前任务使用 `mobile_manage_task(action="stop", trace_id=...)`。停止可能保留已经产生的业务效果,不声称自动回滚。
- 批次暂停只停止后续派发;当前任务等待指导时用 `mobile_manage_task(action="pause", trace_id=...)`,真正取消用 `stop`。服务重启后运行/暂停的任务标记中断,不自动重跑。停止可能保留已经产生的业务效果,不声称自动回滚。
需要编写外部可执行测试代码时,先通过 ARTEMIS 实际探索目标应用,确认页面、定位器和转换。优先动态定位,框架支持时才以已验证坐标兜底;在最终脚本中明确等待条件,不将 AI 思考耗时当作确定性等待。
+18 -10
View File
@@ -2,7 +2,7 @@
## 直接提交用例
用户要求执行明确的测试集合时,将每个独立目标转成一个 case。`parameters` 是简单值字典,`tags` 是字符串,不能填列表;目标中的 `{{参数名}}` 会替换成参数值。前置条件用自然语言描述真实起点。
用户要求执行明确的测试集合时,将每个独立提示词转成一个 case。`goal` 是唯一发送给 ARTEMIS 的任务正文;`case_id`、`category`、`subcategory`、`name`、`preconditions` 只作元数据。`task_config` 可覆盖该项执行配置,不影响其他项。
先生成唯一 `request_id`(例如 UUID),保存在当前任务记录中,然后调用:
@@ -19,13 +19,21 @@
"timeout_seconds": 1800,
"cases": [
{
"case_id": "DEMO-001",
"category": "系统设置",
"subcategory": "首页",
"name": "设置首页可达",
"goal": "打开系统设置,确认设置首页可见,不修改任何设置。",
"expected": "能看到设置首页及至少一个设置分类,记录实际文字与截图证据。",
"app": "com.android.settings",
"preconditions": "设备已解锁。",
"parameters": {},
"tags": "冒烟,只读"
"goal": "打开系统设置,确认设置首页可见,不修改任何设置;记录实际文字与截图证据。",
"task_config": {
"profile": "pro",
"model": "mimo-v2.6-flash",
"reasoning_effort": "high",
"locked_app_package": "com.android.settings",
"record_video": true,
"analyze_video": false,
"request_timeout_seconds": 300
}
}
]
}
@@ -38,19 +46,19 @@
## Excel 导入
`mobile_task_file(action="template")` 返回空白模板的本地路径。标准列为任务名称、任务目标、预期结果、目标应用、前置条件、输入参数(JSON)、标签。每行独立,不支持公式、宏、外部链接。
`mobile_task_file(action="template")` 返回空白模板的本地路径。“任务”页七列依次为:用例编号、检测项目、测试子项、测试用例、前置约束提取、Artemis提示词、任务配置(JSON)。仅提示词必填;说明页有一条示例,不会导入执行。JSON 支持 `profile`、`model`、`reasoning_effort`、`device_serial`、`verification_level`、`explorer_mode`、`locked_app_package`、`record_video`、`analyze_video`、`request_timeout_seconds`、`max_retries`、`retry_interval_seconds`、`timeout_seconds`。未知字段或密钥被拒绝。每行独立,不支持公式、宏、外部链接。
`preview` 返回 `valid/tasks/errors`,不会执行。所有错误处理完后,将 `tasks` 中每项的预览字段 `row` 去掉,作为 `batch.cases` 提交。不要跳过错误行却声称全部导入成功。
## 状态与异常
- 批次 `draft` 等待开始;`running` 调度中;`paused` 等待处理;`completed` 表示所有项进入终态,不代表全部通过。
- 单项 `pending/queued/running/finalizing` 尚未结束,`completed/failed/cancelled/interrupted/skipped` 是终态。`finalizing` 在生成报告,不能提前宣称报告已完成。
- `pause` 保留当前执行。设备断开、模型持续失败或任务达到批次时限会暂停后续派发;用诊断确认恢复后再响应用户的继续指令。
- 单项 `pending/queued/running/task_paused/resuming/finalizing` 尚未结束,`completed/failed/cancelled/interrupted/skipped` 是终态。`finalizing` 在生成报告,不能提前宣称报告已完成。
- 批次 `pause` 保留当前执行,只暂停后续派发;单项 `mobile_manage_task(pause)` 在步骤后释放设备,指导后排队恢复,不阻塞后续项。设备断开、模型持续失败或任务达到批次时限会暂停后续派发;用诊断确认恢复后再响应用户的继续指令。
- `resume` 只继续未开始项;`retry_failed` 复制失败、取消和中断项为新草稿,不修改原结果。必须来自用户的明确重跑请求。该动作不提供创建去重,响应不确定时先 `list` 查找新批次,不自动重复调用。
## 报告
`mobile_export_report(batch_id=...)` 下载批次 ZIP,包含汇总和各项报告,格式由创建批次时的设置决定。单任务传 `session_id`(单任务 MCP 的 `trace_id`)以及可选 `formats=["docx"]`。默认格式通过 `mobile_config(section="reports")` 查看。
`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 文件内容当作文本塞入上下文;先检查工具返回的结构化结论,需要进一步审阅时再用宿主具备的文档工具打开文件。失败和未验证项也必须交付,保持原始证据。
+86 -4
View File
@@ -64,9 +64,9 @@ def workbook_bytes(rows):
def test_template_preview_rejects_formula_and_bad_json():
data = workbook_bytes(
[
["正常", "打开设置", "", "", "", '{"name":"测试"}', "冒烟"],
["公式", "=SUM(1,2)"],
["参数错误", "测试", "", "", "", "{"],
["正常", "设置", "冒烟", "打开设置", "", "观察设置", '{"profile":"flash"}'],
["公式", "", "", "", "", "=SUM(1,2)"],
["参数错误", "", "", "", "", "测试", "{"],
]
)
result = task_import.parse_workbook(data)
@@ -75,6 +75,30 @@ def test_template_preview_rejects_formula_and_bad_json():
assert not result["valid"]
def test_prompt_only_and_per_row_config_validation():
goal = "先查看屏幕,再按实际画面判断。"
data = workbook_bytes(
[
[
"CASE-7",
"检查项目",
"子项",
"用例名称",
"仅作索引的约束",
goal,
'{"profile":"pro","model":"mimo-v2.6-flash","record_video":true}',
],
["CASE-8", "", "", "", "", "观察屏幕", '{"OPENAI_API_KEY":"never"}'],
]
)
preview = task_import.parse_workbook(data)
assert [e["row"] for e in preview["errors"]] == [3]
case = preview["tasks"][0]
assert task_import.case_goal(case) == goal
assert case["preconditions"] == "仅作索引的约束"
assert case["task_config"]["record_video"] is True
def test_invalid_archive_and_empty_template():
with pytest.raises(ValueError):
task_import.parse_workbook(b"not a zip")
@@ -84,7 +108,9 @@ def test_invalid_archive_and_empty_template():
def test_preview_does_not_enqueue(client, isolated):
response = client.post(
"/api/automation/import/preview",
files={"file": ("tasks.xlsx", workbook_bytes([["案例", "观察页面"]]))},
files={
"file": ("tasks.xlsx", workbook_bytes([["案例", "", "", "观察页面", "", "观察页面"]]))
},
)
assert response.status_code == 200
assert response.json()["valid"]
@@ -137,6 +163,30 @@ async def test_batch_admission_idempotent_and_bound_device(isolated, monkeypatch
assert isolated.jobs(batch["id"])[1]["status"] == "pending"
@pytest.mark.asyncio
async def test_paused_case_yields_batch_slot_to_next_case(isolated, monkeypatch):
from apps.admin_console import server
from artemis.runtime.device_pool import DeviceStatus
batch = service.create_batch(payload())
service.batch_action(batch["id"], "resume")
first = isolated.jobs(batch["id"])[0]
isolated.save_job({**first, "status": "task_paused", "started_at": time.time()})
monkeypatch.setattr(
service, "state", type("State", (), {"is_paused": False, "active_runs": {}})()
)
enqueue = AsyncMock(return_value={"tasks": [{"device_serial": "test-device"}]})
monkeypatch.setattr(server.task_queue_service, "enqueue_tasks", enqueue)
monkeypatch.setattr(
"artemis.runtime.device_pool.try_list_devices_async",
AsyncMock(return_value=[DeviceStatus(serial="test-device", state="device")]),
)
await service.tick_batches()
assert enqueue.await_count == 1
assert enqueue.call_args.args[0] == ["观察首页"]
assert [job["status"] for job in isolated.jobs(batch["id"])] == ["task_paused", "queued"]
@pytest.mark.asyncio
async def test_device_outage_pauses_without_consuming_pending_case(isolated, monkeypatch):
batch = service.create_batch(payload())
@@ -193,6 +243,38 @@ def test_reports_do_not_execute_formulas_or_invent_pass(isolated):
assert "未验证".encode() in doc.read("word/document.xml")
def test_task_material_zip_contains_ordered_evidence_and_reports(
client, isolated, tmp_path, monkeypatch
):
job = isolated.jobs(service.create_batch(payload())["id"])[0]
evidence = tmp_path / "evidence"
evidence.mkdir()
first = evidence / "first.png"
second = evidence / "second.png"
video = evidence / "recording.mp4"
for path in (first, second, video):
path.write_bytes(path.name.encode())
monkeypatch.setattr(
reports,
"session_screenshots",
lambda report: [(first, "0001_步骤0001_操作前.png"), (second, "0002_步骤0002_操作后.png")],
)
monkeypatch.setattr(reports, "session_videos", lambda report: [(video, "0001_recording.mp4")])
response = client.get(f"/api/automation/reports/{job['session_id']}/all")
assert response.status_code == 200
with zipfile.ZipFile(BytesIO(response.content)) as archive:
files = archive.namelist()
assert any(name.endswith(".docx") for name in files)
assert any(name.endswith(".xlsx") for name in files)
assert files.index("步骤截图/0001_步骤0001_操作前.png") < files.index(
"步骤截图/0002_步骤0002_操作后.png"
)
assert archive.read("任务录屏/0001_recording.mp4") == b"recording.mp4"
manifest = json.loads(archive.read("文件清单.json"))
assert manifest["session_id"] == job["session_id"]
def test_history_filters_all_persisted_rows_and_escapes_like(client, isolated):
batch = service.create_batch(payload())
first, second = isolated.jobs(batch["id"])
@@ -159,7 +159,7 @@ async def test_shutdown_marks_only_ui_owned_running_sessions():
):
await on_shutdown()
update_status.assert_called_once_with("ui-running", "cancelled")
update_status.assert_called_once_with("ui-running", "interrupted")
assert state.queue_items == []
state.active_session_id = None
state.is_shutting_down = False
@@ -25,7 +25,7 @@ def test_startup_cleanup_keeps_live_cross_process_session(tmp_path):
assert repository.cleanup_orphans_on_startup() == 1
assert repository.get_session_status("live") == "running"
assert repository.get_session_status("dead") == "failed"
assert repository.get_session_status("dead") == "interrupted"
def test_session_list_harvest_rechecks_worker_liveness(tmp_path):
@@ -987,6 +987,7 @@ async def test_manual_stop_of_one_run_does_not_pollute_concurrent_run():
on different devices: device A's task is stopped manually, device B's task
then finishes normally and must still be reported as completed.
"""
state.is_shutting_down = False
ended_payloads: dict[str, dict] = {}
def capture(event_type, data):
+39
View File
@@ -279,6 +279,45 @@ def test_mobile_manage_task_unknown_trace(temp_trace_env):
assert "not found" in res["message"]
def test_web_task_can_be_paused_and_guided_from_mcp(temp_trace_env, monkeypatch):
import sqlite3
from pathlib import Path
import artemis.runtime.task_control as control
monkeypatch.setattr(control, "TRACES_PATH", Path(temp_trace_env))
monkeypatch.setattr("mcp_server.tools.task_manager._pid_alive", lambda pid: True)
trace_id = str(uuid.uuid4())
db = Path(temp_trace_env) / "data_engine.db"
with sqlite3.connect(db) as conn:
conn.execute(
"CREATE TABLE sessions (session_id TEXT, status TEXT, pid INTEGER, "
"start_time REAL, end_time REAL, device_info TEXT)"
)
conn.execute(
"INSERT INTO sessions VALUES (?, 'running', 17, 1, NULL, ?)",
(trace_id, '{"device_id":"test-device"}'),
)
assert (
mobile_manage_task(action="status", trace_id=trace_id)["device_serial"] == "test-device"
)
assert mobile_manage_task(action="pause", trace_id=trace_id)["status"] == "pausing"
assert (Path(temp_trace_env) / trace_id / "pause.request").exists()
control._write_atomic(
control.control_dir(trace_id) / "task_control_state.json", {"status": "task_paused"}
)
guided = mobile_manage_task(
action="inject_instruction", trace_id=trace_id, instruction="先确认当前画面"
)
assert guided["status"] == "task_paused"
assert (Path(temp_trace_env) / trace_id / "resume.request").exists()
assert (
control.consume_instructions(control.control_dir(trace_id))["instruction"]
== "先确认当前画面"
)
def test_mobile_manage_task_status_and_stop(temp_trace_env):
trace_id = str(uuid.uuid4())
trace_store.init_trace(trace_id, "Test task", "Flash", "conv-1", device_serial="device-xyz")
+54
View File
@@ -0,0 +1,54 @@
"""Paused tasks yield their device and resume with a fresh lease."""
import asyncio
import pytest
import artemis.runtime.task_control as task_control
@pytest.mark.asyncio
async def test_pause_releases_device_and_resume_waits_for_new_acquisition(tmp_path, monkeypatch):
monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path)
events = []
class Lock:
def __init__(self, device_id, **kwargs):
self.device_id = device_id
self.description = kwargs.get("description", "test")
self.max_concurrency = kwargs.get("max_concurrency")
self.concurrency_mode = kwargs.get("concurrency_mode", "per_device")
self.ingress = kwargs.get("ingress", "web")
self.lock_scope = kwargs.get("lock_scope", "test")
def release(self):
events.append("released")
def acquire(self, cancel_event=None):
events.append("acquired")
monkeypatch.setattr(task_control, "DeviceExecutionLock", Lock)
original = Lock("test-device")
control = task_control.TaskControl("task-123", original)
task_control.request_pause("task-123")
waiter = asyncio.create_task(
control.checkpoint(
on_pause=lambda: _event("capture_stopped", events),
on_resume=lambda: _event("capture_started", events),
)
)
for _ in range(30):
if task_control.read_state("task-123").get("status") == "task_paused":
break
await asyncio.sleep(0.01)
assert task_control.read_state("task-123")["status"] == "task_paused"
assert events == ["capture_stopped", "released"]
task_control.request_resume("task-123")
assert await asyncio.wait_for(waiter, 2) is True
assert events == ["capture_stopped", "released", "acquired", "capture_started"]
control.release()
assert task_control.read_state("task-123") == {}
async def _event(name, events):
events.append(name)
+9
View File
@@ -58,6 +58,8 @@ if ($ArtemisDownloads.node_mirror -ne 'https://intranet.example/node') { throw '
if ($ArtemisDownloads.python_index -ne 'https://intranet.example/simple') { throw 'Environment ignored' }
if ($env:npm_config_registry -ne 'https://registry.npmmirror.com') { throw 'npm mirror missing' }
if ($env:UV_HTTP_TIMEOUT -ne '180') { throw 'Timeout missing' }
if ($ArtemisDownloads.scrcpy_url -match 'github[.]com') { throw 'China scrcpy still uses GitHub' }
if ($ArtemisDownloads.scrcpy_sha256 -notmatch '^[a-f0-9]{64}$') { throw 'scrcpy digest missing' }
Remove-Item Env:npm_config_registry
Remove-Item Env:UV_PYTHON_INSTALL_MIRROR
Initialize-ArtemisDownloads -ProjectRoot $env:TEST_ROOT -Mode Official
@@ -69,6 +71,13 @@ if ($env:npm_config_registry) { throw 'Official mode selected a China registry'
assert result.returncode == 0, result.stdout + result.stderr
def test_scrcpy_archive_is_checked_before_extraction():
launcher = (ROOT / "scripts/start.ps1").read_text(encoding="utf-8-sig")
assert launcher.index("Get-FileHash -LiteralPath $zipPath") < launcher.index(
"Remove-Item $extractDir -Recurse"
)
@pytest.mark.parametrize("corrupt", [False, True])
def test_uv_mirror_bootstrap_verifies_wheel_without_python(tmp_path, corrupt):
with zipfile.ZipFile(tmp_path / "fixture.zip", "w") as wheel: