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
+1080 -191

No files matched your search

+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 {
"blocked",
"partial",
"failed",
}:
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(