Compare commits

...
4 Commits
45 changed files with 1771 additions and 148 deletions

No files matched your search

+12 -1
View File
@@ -30,6 +30,16 @@ ARTEMIS_SCREENSHOT_LIMIT_ENABLED=true
ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS=3 ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS=3
# Flash 连续无工具调用的最大次数;无效回复不会重复送回模型。 # Flash 连续无工具调用的最大次数;无效回复不会重复送回模型。
ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS=3 ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS=3
# 所有网页/MCP 任务的总执行时限;排队、用户暂停不计入。任务 JSON 的 timeout_seconds 可覆盖。
ARTEMIS_TASK_TIMEOUT_SECONDS=1800
# 单次屏幕观测预算、最多恢复次数和备用 ADB 命令时限。
ARTEMIS_OBSERVATION_TIMEOUT_SECONDS=60
ARTEMIS_OBSERVATION_MAX_ATTEMPTS=3
ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS=10
# Flash 连续设备动作失败后,重新观察并切换兜底模型;兜底仍失败时结束。
ARTEMIS_ACTION_FAILURE_ATTEMPTS=3
# 已写出任务结果后,后台线程/录屏清理最多等待多久;超时回收进程释放队列。
ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS=30
# 视频分段、重试与单次分析超时(秒) # 视频分段、重试与单次分析超时(秒)
ARTEMIS_VIDEO_CHUNK_SECONDS=60 ARTEMIS_VIDEO_CHUNK_SECONDS=60
@@ -40,6 +50,8 @@ ARTEMIS_CHECKPOINT_TIMEOUT_SECONDS=180
ARTEMIS_SETTLEMENT_TIMEOUT_SECONDS=120 ARTEMIS_SETTLEMENT_TIMEOUT_SECONDS=120
ARTEMIS_CREDENTIAL_TIMEOUT_SECONDS=12 ARTEMIS_CREDENTIAL_TIMEOUT_SECONDS=12
ARTEMIS_MODEL_CACHE_TTL_SECONDS=10 ARTEMIS_MODEL_CACHE_TTL_SECONDS=10
# 守护服务轻量探测超时;探测超时且端口仍占用时,不重复启动服务。
ARTEMIS_DAEMON_PROBE_TIMEOUT_SECONDS=2
# 模型重试耗尽后的暂停等待上限(秒);0 表示无限等待 # 模型重试耗尽后的暂停等待上限(秒);0 表示无限等待
LLM_PAUSE_TIMEOUT_SECONDS=900 LLM_PAUSE_TIMEOUT_SECONDS=900
@@ -56,6 +68,5 @@ ARTEMIS_HELPER_AUTO_INSTALL=true
ARTEMIS_DEFAULT_PROFILE=flash ARTEMIS_DEFAULT_PROFILE=flash
# 批量任务与 Excel 导入限制 # 批量任务与 Excel 导入限制
ARTEMIS_BATCH_TASK_TIMEOUT=1800
ARTEMIS_IMPORT_MAX_ROWS=1000 ARTEMIS_IMPORT_MAX_ROWS=1000
ARTEMIS_IMPORT_MAX_BYTES=10485760 ARTEMIS_IMPORT_MAX_BYTES=10485760
+3 -2
View File
@@ -12,6 +12,7 @@ from apps.admin_console.services.automation_store import identifier, store
from apps.admin_console.services import automation_service as service from apps.admin_console.services import automation_service as service
from apps.admin_console.services import batch_archive as archive_service from apps.admin_console.services import batch_archive as archive_service
from apps.admin_console.services import report_export, task_import from apps.admin_console.services import report_export, task_import
from artemis.config.settings import settings
router = APIRouter(prefix="/api/automation", tags=["automation"]) router = APIRouter(prefix="/api/automation", tags=["automation"])
@@ -30,7 +31,7 @@ class BatchRequest(ReportPreferences):
profile: Literal["flash", "pro"] = "flash" profile: Literal["flash", "pro"] = "flash"
stop_on_failure: bool = False stop_on_failure: bool = False
timeout_seconds: int = Field( timeout_seconds: int = Field(
default_factory=lambda: int(os.getenv("ARTEMIS_BATCH_TASK_TIMEOUT", "1800")), default_factory=lambda: settings.ARTEMIS_TASK_TIMEOUT_SECONDS,
ge=30, ge=30,
le=86400, le=86400,
) )
@@ -50,7 +51,7 @@ def require(kind, key):
def preferences(): def preferences():
return { return {
**store.preferences(), **store.preferences(),
"batch_timeout_seconds": int(os.getenv("ARTEMIS_BATCH_TASK_TIMEOUT", "1800")), "batch_timeout_seconds": settings.ARTEMIS_TASK_TIMEOUT_SECONDS,
} }
+7 -2
View File
@@ -202,7 +202,9 @@ async def run_task(request: RunRequest):
# With no explicit serial the probe itself resolves a live target (it # With no explicit serial the probe itself resolves a live target (it
# prefers the diagnostics target preference, then any unlocked ready # prefers the diagnostics target preference, then any unlocked ready
# device); the verified serial is bound below. # device); the verified serial is bound below.
target_serial = request.device_serial target_serial = (
request.runtime_overrides.device_serial if request.runtime_overrides else None
) or request.device_serial
device_probe = await readiness_engine.run_device_submission_probe(target_serial=target_serial) device_probe = await readiness_engine.run_device_submission_probe(target_serial=target_serial)
if device_probe and device_probe.summary in {"Device Locked", "Lock State Unknown"}: if device_probe and device_probe.summary in {"Device Locked", "Lock State Unknown"}:
locked_serial = ( locked_serial = (
@@ -224,7 +226,7 @@ async def run_task(request: RunRequest):
# Only auto-selected targets may be re-bound to the probed device. An # Only auto-selected targets may be re-bound to the probed device. An
# explicitly requested serial is never silently replaced -- if it is # explicitly requested serial is never silently replaced -- if it is
# invalid, enqueue_tasks rejects the submission with a clear error. # invalid, enqueue_tasks rejects the submission with a clear error.
if verified_serial and not request.device_serial: if verified_serial and not target_serial:
target_serial = verified_serial target_serial = verified_serial
response = await task_queue_service.enqueue_tasks( response = await task_queue_service.enqueue_tasks(
@@ -240,6 +242,9 @@ async def run_task(request: RunRequest):
ingress=request.ingress or "frontend", ingress=request.ingress or "frontend",
session_id=request.session_id, session_id=request.session_id,
conversation_id=request.conversation_id, conversation_id=request.conversation_id,
runtime_overrides=request.runtime_overrides.model_dump(exclude_none=True)
if request.runtime_overrides
else None,
) )
from apps.admin_console.services.automation_service import register_submission from apps.admin_console.services.automation_service import register_submission
@@ -13,6 +13,7 @@
# limitations under the License. # limitations under the License.
from pydantic import BaseModel from pydantic import BaseModel
from apps.admin_console.services.task_import import TaskOptions
class RunRequest(BaseModel): class RunRequest(BaseModel):
@@ -32,6 +33,7 @@ class RunRequest(BaseModel):
ingress: str | None = "frontend" ingress: str | None = "frontend"
session_id: str | None = None session_id: str | None = None
conversation_id: str | None = None conversation_id: str | None = None
runtime_overrides: TaskOptions | None = None
class ReplayRequest(BaseModel): class ReplayRequest(BaseModel):
+31 -13
View File
@@ -24,6 +24,7 @@ import os
from pathlib import Path from pathlib import Path
import secrets import secrets
import signal import signal
import socket
import sys import sys
from types import FrameType from types import FrameType
@@ -421,6 +422,14 @@ queue_goals = state.queue_goals
class ArtemisUvicornServer(uvicorn.Server): class ArtemisUvicornServer(uvicorn.Server):
"""Notify application streams before Uvicorn waits for them to close.""" """Notify application streams before Uvicorn waits for them to close."""
async def startup(self, sockets: list[socket.socket] | None = None) -> None:
# Reload workers start in a fresh interpreter; restore the actual bind
# address there before lifespan publishes runtime metadata.
state.host = self.config.host
state.port = self.config.port
app.state.uvicorn_server = self
await super().startup(sockets=sockets)
@staticmethod @staticmethod
def _task_is_active() -> bool: def _task_is_active() -> bool:
"""Check task state without doing process I/O from a signal handler.""" """Check task state without doing process I/O from a signal handler."""
@@ -470,31 +479,40 @@ class ArtemisUvicornServer(uvicorn.Server):
def run_ui_server(host: str, port: int, reload: bool = False) -> None: def run_ui_server(host: str, port: int, reload: bool = False) -> None:
"""Run the UI server with bounded, signal-aware graceful shutdown.""" """Run the UI server with bounded, signal-aware graceful shutdown."""
# Reserve a listening socket before publishing metadata or running lifespan.
# Uvicorn normally runs application startup *before* binding its port; a
# duplicate daemon would otherwise recover jobs owned by the real server.
family = socket.AF_INET6 if ":" in host else socket.AF_INET
try:
listener = socket.create_server((host, port), family=family)
except OSError as exc:
print(f"无法启动服务:{host}:{port} 端口不可用({exc})。现有服务和任务保持不变。")
raise SystemExit(1) from exc
listener.set_inheritable(True)
port = listener.getsockname()[1]
state.host = host state.host = host
state.port = port state.port = port
write_server_info(port=port, host=host, lifecycle_token=LIFECYCLE_TOKEN)
try: try:
if reload:
uvicorn.run(
"apps.admin_console.server:app",
host=host,
port=port,
reload=True,
timeout_graceful_shutdown=5,
)
return
config = uvicorn.Config( config = uvicorn.Config(
app, "apps.admin_console.server:app" if reload else app,
host=host, host=host,
port=port, port=port,
reload=reload,
timeout_graceful_shutdown=5, timeout_graceful_shutdown=5,
) )
if reload:
from uvicorn.supervisors import ChangeReload
server = ArtemisUvicornServer(config)
ChangeReload(config, target=server.run, sockets=[listener]).run()
return
server = ArtemisUvicornServer(config) server = ArtemisUvicornServer(config)
app.state.uvicorn_server = server app.state.uvicorn_server = server
server.run() server.run(sockets=[listener])
finally: finally:
listener.close()
app.state.uvicorn_server = None app.state.uvicorn_server = None
clear_server_info(port=port, lifecycle_token=LIFECYCLE_TOKEN) clear_server_info(port=port, lifecycle_token=LIFECYCLE_TOKEN)
@@ -8,8 +8,10 @@ import time
from uuid import UUID from uuid import UUID
from apps.admin_console.core.state import state from apps.admin_console.core.state import state
from apps.admin_console.services.automation_store import store, new_id from apps.admin_console.services.automation_store import RECOVERY_ERRORS, store, new_id
from apps.admin_console.services.task_import import case_goal, case_title from apps.admin_console.services.task_import import case_goal, case_title
from artemis.config.settings import settings
from artemis.runtime.task_limits import task_timeout
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
TERMINAL = {"completed", "failed", "cancelled", "skipped", "interrupted"} TERMINAL = {"completed", "failed", "cancelled", "skipped", "interrupted"}
@@ -40,9 +42,46 @@ def register_submission(item: dict, case: dict | None = None, formats=None, **ex
) )
def resolve_recovery_error(job: dict) -> bool:
"""Archive a superseded recovery warning without erasing execution failures."""
if job.get("error") not in RECOVERY_ERRORS:
return False
job.setdefault("recovery_events", []).append(
{"message": job.pop("error"), "resolved_at": time.time()}
)
return True
def recover_batches(): def recover_batches():
"""Recovery never silently repeats a potentially side-effecting interrupted task.""" """Recovery never silently repeats a potentially side-effecting interrupted task."""
from apps.admin_console.database.repositories.session_repository import SessionRepository
from artemis.runtime import DeviceExecutionLock
repository = SessionRepository(store.path)
live_sessions = {
str(owner.session_id)
for owner in DeviceExecutionLock.get_active_owners().values()
if owner.session_id
}
live_sessions.update(
str(row["session_id"])
for row in repository.get_all_sessions()
if row.get("status") not in TERMINAL and repository.process_is_alive(row.get("pid"))
)
live_sessions.update(
str(item["session_id"])
for item in state.queue_items
if isinstance(item, dict) and item.get("session_id") and item.get("status") not in TERMINAL
)
for job in store.jobs(): for job in store.jobs():
if job["status"] == "completed" and resolve_recovery_error(job):
store.save_job(job)
if str(job["session_id"]) in live_sessions:
if job["status"] not in TERMINAL and str(job["session_id"]) not in state.active_runs:
job["recovery_waiting"] = True
store.save_job(job)
logger.info("Recovery preserved live session %s", job["session_id"])
continue
if job["status"] in {"running", "queued", "finalizing", "task_paused", "resuming"}: if job["status"] in {"running", "queued", "finalizing", "task_paused", "resuming"}:
job.update( job.update(
status=job.get("terminal_status", "interrupted"), status=job.get("terminal_status", "interrupted"),
@@ -50,6 +89,9 @@ def recover_batches():
or ("" if job.get("terminal_status") else "服务中断;未完成任务需手动重跑"), or ("" if job.get("terminal_status") else "服务中断;未完成任务需手动重跑"),
ended_at=job.get("ended_at") or time.time(), ended_at=job.get("ended_at") or time.time(),
) )
if job["status"] == "completed":
resolve_recovery_error(job)
logger.info("Recovery resolved session %s to %s", job["session_id"], job["status"])
store.save_job(job) store.save_job(job)
for batch in store.records("batch"): for batch in store.records("batch"):
if has_legacy_replay(batch["id"]): if has_legacy_replay(batch["id"]):
@@ -62,6 +104,8 @@ def recover_batches():
store.put("batch", batch["id"], batch) store.put("batch", batch["id"], batch)
continue continue
if batch["status"] in {"running", "pausing", "paused"}: if batch["status"] in {"running", "pausing", "paused"}:
if any(str(job["session_id"]) in live_sessions for job in store.jobs(batch["id"])):
continue
batch.update( batch.update(
status="paused", reason="服务已重启;请检查中断任务,点击继续执行未开始任务" status="paused", reason="服务已重启;请检查中断任务,点击继续执行未开始任务"
) )
@@ -86,6 +130,9 @@ async def on_terminal(item: dict, status: str):
raise RuntimeError("任务元数据写入失败") raise RuntimeError("任务元数据写入失败")
if job.get("timed_out"): if job.get("timed_out"):
status = "failed" status = "failed"
job.pop("recovery_waiting", None)
if status == "completed":
resolve_recovery_error(job)
job.update(status="finalizing", terminal_status=status, ended_at=time.time()) job.update(status="finalizing", terminal_status=status, ended_at=time.time())
store.save_job(job) store.save_job(job)
try: try:
@@ -115,11 +162,37 @@ async def on_terminal(item: dict, status: str):
store.save_job(job) store.save_job(job)
async def reconcile_recovered_jobs():
"""Finalize surviving workers once they exit; never resubmit their goals."""
from apps.admin_console.database.repositories.session_repository import SessionRepository
from artemis.runtime import DeviceExecutionLock
from artemis.runtime.task_result import read_worker_outcome
waiting = [job for job in store.jobs() if job.get("recovery_waiting")]
if not waiting:
return
repository = SessionRepository(store.path)
owners = {str(owner.session_id) for owner in DeviceExecutionLock.get_active_owners().values()}
for job in waiting:
sid = str(job["session_id"])
row = repository.get_session_by_id(sid) or {}
if sid in owners or repository.process_is_alive(row.get("pid")):
continue
outcome = read_worker_outcome(sid)
status = job.get("terminal_status") or outcome.get("task_status") or row.get("status")
if status not in TERMINAL:
status = "interrupted"
job["error"] = job.get("error") or "服务中断;未完成任务需手动重跑"
store.save_job(job)
await on_terminal({"session_id": sid}, status)
async def tick_batches(): async def tick_batches():
from apps.admin_console.server import task_queue_service from apps.admin_console.server import task_queue_service
from artemis.runtime import device_pool from artemis.runtime import device_pool
from apps.admin_console.services.report_export import export_batch from apps.admin_console.services.report_export import export_batch
await reconcile_recovered_jobs()
for batch in store.records("batch"): for batch in store.records("batch"):
if batch["status"] not in {"running", "paused"}: if batch["status"] not in {"running", "paused"}:
continue continue
@@ -135,10 +208,11 @@ async def tick_batches():
# not consume this case's execution budget. # not consume this case's execution budget.
if job["status"] != "running": if job["status"] != "running":
continue continue
limit = int( from artemis.runtime.task_result import read_worker_outcome
(job.get("case", {}).get("task_config") or {}).get("timeout_seconds")
or batch.get("timeout_seconds", 1800) if read_worker_outcome(str(job["session_id"])):
) continue # Execution has ended; the worker supervisor bounds cleanup.
limit = task_timeout({}, job, batch)
task_state = getattr(state, "active_runs", {}).get(str(job["session_id"])) or {} task_state = getattr(state, "active_runs", {}).get(str(job["session_id"])) or {}
excluded = float(task_state.get("non_execution_seconds") or 0) excluded = float(task_state.get("non_execution_seconds") or 0)
if task_state.get("non_execution_started_at"): if task_state.get("non_execution_started_at"):
@@ -151,7 +225,17 @@ async def tick_batches():
error=f"任务执行超时({limit} 秒,不含暂停/排队时间)", timed_out=True error=f"任务执行超时({limit} 秒,不含暂停/排队时间)", timed_out=True
) )
store.save_job(job) store.save_job(job)
task_queue_service.stop_tasks(session_id=job["session_id"]) from artemis.runtime.task_result import save_failure_result
try:
await asyncio.to_thread(
save_failure_result,
str(job["session_id"]),
job["error"],
code="task_timeout",
)
finally:
task_queue_service.stop_tasks(session_id=job["session_id"])
# on_terminal records the failed case. The next pending # on_terminal records the failed case. The next pending
# case then runs normally unless stop_on_failure is set. # case then runs normally unless stop_on_failure is set.
continue continue
@@ -255,8 +339,7 @@ def create_batch(payload: dict) -> dict:
"formats": payload.get("formats") or store.preferences()["formats"], "formats": payload.get("formats") or store.preferences()["formats"],
"profile": payload.get("profile", "flash"), "profile": payload.get("profile", "flash"),
"device_serial": payload.get("device_serial"), "device_serial": payload.get("device_serial"),
"timeout_seconds": payload.get("timeout_seconds") "timeout_seconds": payload.get("timeout_seconds") or settings.ARTEMIS_TASK_TIMEOUT_SECONDS,
or int(os.getenv("ARTEMIS_BATCH_TASK_TIMEOUT", "1800")),
"stop_on_failure": payload.get("stop_on_failure", False), "stop_on_failure": payload.get("stop_on_failure", False),
"request_id": payload.get("request_id"), "request_id": payload.get("request_id"),
} }
@@ -12,6 +12,12 @@ import uuid
from artemis.config import DB_PATH from artemis.config import DB_PATH
RECOVERY_ERRORS = {
"服务中断;未完成任务需手动重跑",
"服务中断,需手动重跑",
}
def identifier(value: str) -> str: def identifier(value: str) -> str:
if not re.fullmatch(r"[A-Za-z0-9_-]{1,100}", value): if not re.fullmatch(r"[A-Za-z0-9_-]{1,100}", value):
raise ValueError("无效的记录编号") raise ValueError("无效的记录编号")
@@ -172,6 +172,24 @@ class CapabilitySettings(BaseModel):
step_summarizer_enabled: bool = True step_summarizer_enabled: bool = True
screenshot_limit_enabled: bool = True screenshot_limit_enabled: bool = True
screenshot_max_long_edge: int = 2048 screenshot_max_long_edge: int = 2048
task_timeout_seconds: int = Field(
default_factory=lambda: settings.ARTEMIS_TASK_TIMEOUT_SECONDS, ge=30, le=86400
)
observation_timeout_seconds: float = Field(
default_factory=lambda: settings.ARTEMIS_OBSERVATION_TIMEOUT_SECONDS, gt=0, le=300
)
observation_max_attempts: int = Field(
default_factory=lambda: settings.ARTEMIS_OBSERVATION_MAX_ATTEMPTS, ge=1, le=5
)
device_command_timeout_seconds: float = Field(
default_factory=lambda: settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS, gt=0, le=60
)
action_failure_attempts: int = Field(
default_factory=lambda: settings.ARTEMIS_ACTION_FAILURE_ATTEMPTS, ge=1, le=10
)
worker_cleanup_timeout_seconds: float = Field(
default_factory=lambda: settings.ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS, gt=0, le=300
)
def get_capability_settings() -> CapabilitySettings: def get_capability_settings() -> CapabilitySettings:
@@ -193,6 +211,12 @@ def save_capability_settings(update: CapabilitySettings) -> CapabilitySettings:
"ARTEMIS_OUTPUTTER_ENABLED": update.outputter_enabled, "ARTEMIS_OUTPUTTER_ENABLED": update.outputter_enabled,
"ARTEMIS_STEP_SUMMARIZER_ENABLED": update.step_summarizer_enabled, "ARTEMIS_STEP_SUMMARIZER_ENABLED": update.step_summarizer_enabled,
"ARTEMIS_SCREENSHOT_LIMIT_ENABLED": update.screenshot_limit_enabled, "ARTEMIS_SCREENSHOT_LIMIT_ENABLED": update.screenshot_limit_enabled,
"ARTEMIS_TASK_TIMEOUT_SECONDS": update.task_timeout_seconds,
"ARTEMIS_OBSERVATION_TIMEOUT_SECONDS": update.observation_timeout_seconds,
"ARTEMIS_OBSERVATION_MAX_ATTEMPTS": update.observation_max_attempts,
"ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS": update.device_command_timeout_seconds,
"ARTEMIS_ACTION_FAILURE_ATTEMPTS": update.action_failure_attempts,
"ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS": update.worker_cleanup_timeout_seconds,
} }
persist_environment({key: str(value).lower() for key, value in values.items()}) persist_environment({key: str(value).lower() for key, value in values.items()})
for key, value in values.items(): for key, value in values.items():
@@ -88,6 +88,10 @@ def failure_reason(directory: Path, status: str, recorded: str | None) -> str:
) )
return safe[:500] return safe[:500]
from apps.admin_console.services.automation_store import RECOVERY_ERRORS
if status == "completed" and recorded in RECOVERY_ERRORS:
return ""
if recorded: if recorded:
return redact(recorded) return redact(recorded)
if status not in {"failed", "interrupted"}: if status not in {"failed", "interrupted"}:
@@ -95,6 +99,11 @@ def failure_reason(directory: Path, status: str, recorded: str | None) -> str:
state = read_json(directory / "status.json", {}) state = read_json(directory / "status.json", {})
if isinstance(state, dict) and state.get("error"): if isinstance(state, dict) and state.get("error"):
return redact(state["error"]) return redact(state["error"])
result = read_json(directory / "result.json", {})
if isinstance(result, dict) and result.get("status") == "failed":
reason = result.get("error") or result.get("explanation")
if reason:
return redact(reason)
log_lines: list[str] = [] log_lines: list[str] = []
for name in ("stdout.log", "stderr.log"): for name in ("stdout.log", "stderr.log"):
path = directory / name path = directory / name
@@ -42,6 +42,11 @@ class TaskOptions(BaseModel):
max_retries: int | None = Field(default=None, ge=0, le=10) max_retries: int | None = Field(default=None, ge=0, le=10)
retry_interval_seconds: float | None = Field(default=None, ge=0, le=300) retry_interval_seconds: float | None = Field(default=None, ge=0, le=300)
timeout_seconds: int | None = Field(default=None, ge=30, le=86400) timeout_seconds: int | None = Field(default=None, ge=30, le=86400)
observation_timeout_seconds: float | None = Field(default=None, gt=0, le=300)
observation_max_attempts: int | None = Field(default=None, ge=1, le=5)
device_command_timeout_seconds: float | None = Field(default=None, gt=0, le=60)
action_failure_attempts: int | None = Field(default=None, ge=1, le=10)
worker_cleanup_timeout_seconds: float | None = Field(default=None, gt=0, le=300)
class TaskCase(BaseModel): class TaskCase(BaseModel):
@@ -581,6 +581,10 @@ class TaskQueueService:
"retry_interval_seconds": "OPENAI_RETRY_INTERVAL_SECONDS", "retry_interval_seconds": "OPENAI_RETRY_INTERVAL_SECONDS",
"record_video": "ARTEMIS_RECORDING_ENABLED", "record_video": "ARTEMIS_RECORDING_ENABLED",
"analyze_video": "ARTEMIS_VIDEO_ENABLED", "analyze_video": "ARTEMIS_VIDEO_ENABLED",
"observation_timeout_seconds": "ARTEMIS_OBSERVATION_TIMEOUT_SECONDS",
"observation_max_attempts": "ARTEMIS_OBSERVATION_MAX_ATTEMPTS",
"device_command_timeout_seconds": "ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS",
"action_failure_attempts": "ARTEMIS_ACTION_FAILURE_ATTEMPTS",
}.items(): }.items():
if overrides.get(option) is not None: if overrides.get(option) is not None:
env[variable] = ( env[variable] = (
@@ -630,6 +634,72 @@ class TaskQueueService:
env["RESULTS_OUTPUT_PATH"] = str(TRACES_PATH / str(sess_id) / "result.json") env["RESULTS_OUTPUT_PATH"] = str(TRACES_PATH / str(sess_id) / "result.json")
return cmd, env return cmd, env
@classmethod
async def _await_worker_with_budget(cls, proc, task_item: dict, run_key: str) -> int:
from artemis.runtime.task_limits import ExecutionBudget, task_timeout
from artemis.config.settings import settings
from artemis.runtime.task_result import save_failure_result, read_worker_outcome
from artemis.runtime.task_control import read_state
from apps.admin_console.services.automation_store import store
sid = str(task_item.get("session_id") or run_key)
job = store.job(sid)
batch = store.get("batch", job["batch_id"]) if job and job.get("batch_id") else None
limit = task_timeout(task_item, job, batch)
budget = ExecutionBudget(limit)
cleanup_limit = (task_item.get("runtime_overrides") or {}).get(
"worker_cleanup_timeout_seconds"
) or settings.ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS
finalized_at = None
waiter = asyncio.create_task(cls._wait_for_worker_process(proc))
try:
while True:
done, _ = await asyncio.wait({waiter}, timeout=0.25)
if waiter in done:
return waiter.result()
outcome = read_worker_outcome(sid)
if outcome:
finalized_at = finalized_at if finalized_at is not None else time.monotonic()
if time.monotonic() - finalized_at >= cleanup_limit:
logger.warning(
"%s: task finalized but worker cleanup exceeded %s seconds; reclaiming process",
sid,
cleanup_limit,
)
await cls._terminate_worker_process(proc)
return await waiter
continue # Final result is authoritative; only bound resource cleanup now.
cls._sync_control_states()
run = state.active_runs.get(run_key, {})
remaining = budget.update(
excluded=bool(run.get("parked")) or read_state(sid).get("status") == "queued"
)
if remaining > 0:
continue
error = f"任务执行超时({limit} 秒,不含暂停/排队时间)"
task_item.update(timed_out=True, error=error)
logger.error("%s: %s", sid, error)
try:
latest = store.job(sid)
if latest:
latest.update(timed_out=True, error=error)
store.save_job(latest)
except Exception:
logger.exception(
"Cannot persist timeout for %s; terminating worker anyway", sid
)
await cls._terminate_worker_process(proc)
returncode = await waiter
try:
await asyncio.to_thread(save_failure_result, sid, error, code="task_timeout")
except OSError:
logger.exception("Cannot save timeout report for %s", sid)
return returncode
finally:
if not waiter.done():
waiter.cancel()
await asyncio.gather(waiter, return_exceptions=True)
@classmethod @classmethod
def _register_worker_run( def _register_worker_run(
cls, cls,
@@ -721,6 +791,10 @@ class TaskQueueService:
returncode, returncode,
manual_stop, manual_stop,
) )
from apps.admin_console.services.automation_store import store
if (store.job(str(sess_id)) or {}).get("timed_out"):
new_status, should_persist = "failed", True
if ( if (
state.is_shutting_down state.is_shutting_down
and not manual_stop and not manual_stop
@@ -943,7 +1017,7 @@ class TaskQueueService:
await cls._terminate_if_cancelled_during_launch(run_key, sess_id, proc) await cls._terminate_if_cancelled_during_launch(run_key, sess_id, proc)
# 3. Await subprocess completion # 3. Await subprocess completion
returncode = await cls._wait_for_worker_process(proc) returncode = await cls._await_worker_with_budget(proc, task_item, run_key)
print(f"[QueueWorker] Task [{sess_id}] exited with returncode {returncode}") print(f"[QueueWorker] Task [{sess_id}] exited with returncode {returncode}")
manual_stop = run_key in state.manually_stopped_run_ids or bool( manual_stop = run_key in state.manually_stopped_run_ids or bool(
@@ -1141,6 +1215,12 @@ class TaskQueueService:
forwarded to the worker as ``--verification-level`` / ``--explorer-pro-mode``; forwarded to the worker as ``--verification-level`` / ``--explorer-pro-mode``;
they are normalised here so the queue item and the CLI see one spelling. they are normalised here so the queue item and the CLI see one spelling.
""" """
if runtime_overrides:
profile = runtime_overrides.get("profile") or profile
device_serial = runtime_overrides.get("device_serial") or device_serial
locked_app_package = runtime_overrides.get("locked_app_package") or locked_app_package
verification_level = runtime_overrides.get("verification_level") or verification_level
explorer_mode = runtime_overrides.get("explorer_mode") or explorer_mode
verification_level = ( verification_level = (
str(verification_level).strip().lower() or None if verification_level else None str(verification_level).strip().lower() or None if verification_level else None
) )
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "artemis-showcase-ui", "name": "artemis-showcase-ui",
"version": "1.3.0", "version": "1.3.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "artemis-showcase-ui", "name": "artemis-showcase-ui",
"version": "1.3.0", "version": "1.3.1",
"dependencies": { "dependencies": {
"@angular/cdk": "^22.1.5", "@angular/cdk": "^22.1.5",
"@angular/common": "^22.1.4", "@angular/common": "^22.1.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "artemis-showcase-ui", "name": "artemis-showcase-ui",
"version": "1.3.0", "version": "1.3.1",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",
"start": "ng serve --proxy-config proxy.conf.json", "start": "ng serve --proxy-config proxy.conf.json",
@@ -338,6 +338,8 @@ limitations under the License.
playsinline playsinline
(loadedmetadata)="onLoadedMetadata()" (loadedmetadata)="onLoadedMetadata()"
(timeupdate)="onTimeUpdate()" (timeupdate)="onTimeUpdate()"
(seeked)="onSeeked()"
(emptied)="onPause()"
(play)="onPlay()" (play)="onPlay()"
(pause)="onPause()" (pause)="onPause()"
(ended)="onEnded()" (ended)="onEnded()"
@@ -1,17 +1,26 @@
import { TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { signal } from '@angular/core'; import { signal } from '@angular/core';
import { AgentService } from '../../services/agent.service'; import { AgentService } from '../../services/agent.service';
import { StepReplayFrame } from '../../core/models/stream.model'; import { StepReplayFrame } from '../../core/models/stream.model';
import { FloatingVideoPlayerComponent } from './floating-video-player.component'; import { FloatingVideoPlayerComponent } from './floating-video-player.component';
describe('FloatingVideoPlayerComponent video markers', () => { describe('FloatingVideoPlayerComponent video markers', () => {
it('shows a recorded step on the video timeline and seeks when clicked', () => { let fixture: ComponentFixture<FloatingVideoPlayerComponent>;
let component: FloatingVideoPlayerComponent;
let video: HTMLVideoElement;
let paused: boolean;
let seeking: boolean;
let time: number;
let play: jasmine.Spy;
let agentService: ReturnType<typeof createAgentService>;
function createAgentService() {
const start = 1_800_000_000; const start = 1_800_000_000;
const frame: StepReplayFrame = { const frame: StepReplayFrame = {
index: 0, stepNumber: 1, title: '步骤 1: 点击设置', imageUrl: 'step.png', index: 0, stepNumber: 1, title: '步骤 1: 点击设置', imageUrl: 'step.png',
actionText: '点击设置', timestamp: start + 5 actionText: '点击设置', timestamp: start + 5
}; };
const agentService = { return {
isVideoWindowOpen: signal(true), isVideoWindowOpen: signal(true),
isVideoMinimized: signal(false), isVideoMinimized: signal(false),
recordingPlaybackStatus: signal('ready'), recordingPlaybackStatus: signal('ready'),
@@ -25,15 +34,57 @@ describe('FloatingVideoPlayerComponent video markers', () => {
currentSession: signal({ session_id: 'test', start_time: start }), currentSession: signal({ session_id: 'test', start_time: start }),
currentSessionId: signal('test'), currentSessionId: signal('test'),
videoSeekRequest: signal(null), videoSeekRequest: signal(null),
stepSeekRequest: signal(null) stepSeekRequest: signal(null),
consumeVideoAutoplay: jasmine.createSpy('consumeVideoAutoplay').and.returnValue(false)
}; };
}
beforeEach(() => {
agentService = createAgentService();
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [FloatingVideoPlayerComponent], imports: [FloatingVideoPlayerComponent],
providers: [{ provide: AgentService, useValue: agentService }] providers: [{ provide: AgentService, useValue: agentService }]
}); });
const fixture = TestBed.createComponent(FloatingVideoPlayerComponent); fixture = TestBed.createComponent(FloatingVideoPlayerComponent);
fixture.detectChanges(); fixture.detectChanges();
component = fixture.componentInstance;
video = component.videoRef!.nativeElement;
paused = true;
seeking = false;
time = 0;
Object.defineProperties(video, {
paused: { configurable: true, get: () => paused },
seeking: { configurable: true, get: () => seeking },
ended: { configurable: true, get: () => false },
duration: { configurable: true, get: () => 20 },
currentTime: {
configurable: true, get: () => time,
set: (value: number) => { time = value; seeking = true; }
}
});
spyOn(video, 'pause').and.callFake(() => {
paused = true;
video.dispatchEvent(new Event('pause'));
});
play = spyOn(video, 'play').and.callFake(() => {
paused = false;
video.dispatchEvent(new Event('play'));
return Promise.resolve();
});
});
function finishSeek(): void {
seeking = false;
video.dispatchEvent(new Event('seeked'));
fixture.detectChanges();
}
function playButton(): HTMLButtonElement {
return fixture.nativeElement.querySelector('.play-btn');
}
it('shows a recorded step on the video timeline and seeks when clicked', () => {
const marker = fixture.nativeElement.querySelector('.video-marker') as HTMLButtonElement; const marker = fixture.nativeElement.querySelector('.video-marker') as HTMLButtonElement;
expect(marker).not.toBeNull(); expect(marker).not.toBeNull();
expect(marker.getAttribute('aria-label')).toContain('步骤 1'); expect(marker.getAttribute('aria-label')).toContain('步骤 1');
@@ -43,4 +94,150 @@ describe('FloatingVideoPlayerComponent video markers', () => {
marker.click(); marker.click();
expect(seek).toHaveBeenCalledOnceWith(5); expect(seek).toHaveBeenCalledOnceWith(5);
}); });
it('resumes a playing video after a marker seek finishes and synchronizes the button', async () => {
component.togglePlay();
await Promise.resolve();
play.calls.reset();
fixture.nativeElement.querySelector('.video-marker').click();
expect(time).toBe(5);
expect(play).not.toHaveBeenCalled();
finishSeek();
await Promise.resolve();
fixture.detectChanges();
expect(paused).toBeFalse();
expect(play).toHaveBeenCalledTimes(1);
expect(playButton().title).toBe('暂停');
});
it('keeps a paused video paused after a marker seek and resumes with one click', async () => {
fixture.nativeElement.querySelector('.video-marker').click();
finishSeek();
expect(paused).toBeTrue();
expect(play).not.toHaveBeenCalled();
expect(playButton().title).toBe('播放');
playButton().click();
await Promise.resolve();
fixture.detectChanges();
expect(paused).toBeFalse();
expect(playButton().title).toBe('暂停');
});
it('preserves playing intent across rapid consecutive marker seeks', () => {
component.togglePlay();
component.seek(5);
component.seek(8);
play.calls.reset();
finishSeek();
expect(time).toBe(8);
expect(play).toHaveBeenCalledTimes(1);
expect(paused).toBeFalse();
});
it('honors play and pause clicks while the destination is still seeking', () => {
component.togglePlay();
component.seek(5);
play.calls.reset();
fixture.detectChanges();
expect(playButton().title).toBe('播放');
playButton().click();
fixture.detectChanges();
expect(playButton().title).toBe('暂停');
expect(play).not.toHaveBeenCalled();
playButton().click();
finishSeek();
expect(paused).toBeTrue();
expect(play).not.toHaveBeenCalled();
expect(playButton().title).toBe('播放');
});
it('waits for the destination segment to seek before resuming playback', () => {
agentService.activeVideoSegments.set([
{ url: '/test.mp4', start: 0, duration: 20, width: 400, height: 800 },
{ url: '/second.mp4', start: 20, duration: 20, width: 400, height: 800 }
]);
fixture.detectChanges();
component.togglePlay();
component.seek(25);
play.calls.reset();
fixture.detectChanges();
video.dispatchEvent(new Event('loadedmetadata'));
expect(component.activeSegmentIndex()).toBe(1);
expect(time).toBe(5);
expect(play).not.toHaveBeenCalled();
finishSeek();
expect(play).toHaveBeenCalledTimes(1);
expect(paused).toBeFalse();
expect(component.currentTime()).toBe(25);
});
it('keeps a paused video paused when jumping to another segment', () => {
agentService.activeVideoSegments.set([
{ url: '/test.mp4', start: 0, duration: 20, width: 400, height: 800 },
{ url: '/second.mp4', start: 20, duration: 20, width: 400, height: 800 }
]);
fixture.detectChanges();
component.seek(25);
fixture.detectChanges();
video.dispatchEvent(new Event('loadedmetadata'));
finishSeek();
expect(time).toBe(5);
expect(paused).toBeTrue();
expect(play).not.toHaveBeenCalled();
expect(playButton().title).toBe('播放');
});
it('resumes after seeking a recording without a segment manifest', () => {
agentService.activeVideoSegments.set([]);
fixture.detectChanges();
video.dispatchEvent(new Event('loadedmetadata'));
component.togglePlay();
component.seek(5);
play.calls.reset();
finishSeek();
expect(time).toBe(5);
expect(component.currentTime()).toBe(5);
expect(paused).toBeFalse();
expect(play).toHaveBeenCalledTimes(1);
});
it('resets a rejected play request so a single subsequent click can resume', async () => {
play.and.callFake(() => {
paused = false;
video.dispatchEvent(new Event('play'));
return Promise.reject(new DOMException('Interrupted', 'AbortError'));
});
component.seek(5, true);
finishSeek();
await Promise.resolve();
await Promise.resolve();
fixture.detectChanges();
expect(paused).toBeTrue();
expect(playButton().title).toBe('播放');
play.and.callFake(() => {
paused = false;
video.dispatchEvent(new Event('play'));
return Promise.resolve();
});
playButton().click();
await Promise.resolve();
fixture.detectChanges();
expect(paused).toBeFalse();
expect(playButton().title).toBe('暂停');
});
it('ignores an older aborted play request after a newer seek resumes', async () => {
let rejectOldPlay!: (reason: unknown) => void;
play.and.returnValue(new Promise<void>((_, reject) => { rejectOldPlay = reject; }));
component.togglePlay();
paused = false;
component.seek(5);
play.and.callFake(() => { paused = false; return Promise.resolve(); });
finishSeek();
rejectOldPlay(new DOMException('Interrupted', 'AbortError'));
await Promise.resolve();
await Promise.resolve();
expect(paused).toBeFalse();
expect(component.isPlaying()).toBeTrue();
});
}); });
@@ -71,6 +71,8 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
private pendingLocalTime: number | null = null; private pendingLocalTime: number | null = null;
private pendingAutoplay = false; private pendingAutoplay = false;
private pendingAbsoluteSeek: number | null = null; private pendingAbsoluteSeek: number | null = null;
private seekPlaybackIntent: boolean | null = null;
private playRequestId = 0;
// Step Replay state signals // Step Replay state signals
public activeStepIndex = signal<number>(0); public activeStepIndex = signal<number>(0);
@@ -138,6 +140,10 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
this.currentTime.set(0); this.currentTime.set(0);
this.duration.set(segments.reduce((sum, segment) => sum + segment.duration, 0)); this.duration.set(segments.reduce((sum, segment) => sum + segment.duration, 0));
this.isPlaying.set(false); this.isPlaying.set(false);
this.pendingLocalTime = null;
this.pendingAutoplay = false;
this.seekPlaybackIntent = null;
this.playRequestId++;
}, },
{ allowSignalWrites: true } { allowSignalWrites: true }
); );
@@ -315,20 +321,19 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
// that segment's metadata event apply pendingLocalTime. // that segment's metadata event apply pendingLocalTime.
if (this.pendingLocalTime !== null) return; if (this.pendingLocalTime !== null) return;
} }
if (this.pendingLocalTime !== null) { const autoplay = this.agentService.consumeVideoAutoplay();
v.currentTime = Math.max(0, Math.min(v.duration || this.pendingLocalTime, this.pendingLocalTime)); if (autoplay) {
this.pendingLocalTime = null;
}
if (this.pendingAutoplay) {
this.pendingAutoplay = false;
v.play().catch(() => {});
}
if (this.agentService.consumeVideoAutoplay()) {
this.isMuted.set(true); this.isMuted.set(true);
v.muted = true; v.muted = true;
v.play().catch(() => { }
this.isPlaying.set(false); const shouldPlay = this.pendingAutoplay || autoplay;
}); this.pendingAutoplay = false;
if (this.pendingLocalTime !== null) {
const localTime = Math.max(0, Math.min(v.duration || this.pendingLocalTime, this.pendingLocalTime));
this.pendingLocalTime = null;
this.seekWithinVideo(localTime, shouldPlay);
} else if (shouldPlay) {
this.playVideo();
} }
} }
} }
@@ -346,14 +351,28 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
} }
public onPlay(): void { public onPlay(): void {
this.isPlaying.set(true); this.isPlaying.set(!this.videoRef?.nativeElement.paused);
} }
public onPause(): void { public onPause(): void {
this.isPlaying.set(false); this.isPlaying.set(false);
} }
public onSeeked(): void {
const v = this.videoRef?.nativeElement;
if (!v || v.seeking || this.pendingLocalTime !== null) return;
const shouldPlay = this.seekPlaybackIntent;
this.seekPlaybackIntent = null;
this.onTimeUpdate();
if (shouldPlay) {
this.playVideo();
} else {
this.isPlaying.set(!v.paused && !v.ended);
}
}
public onEnded(): void { public onEnded(): void {
this.isPlaying.set(false);
const segments = this.agentService.activeVideoSegments(); const segments = this.agentService.activeVideoSegments();
if (segments.length && this.activeSegmentIndex() < segments.length - 1) { if (segments.length && this.activeSegmentIndex() < segments.length - 1) {
this.pendingLocalTime = 0; this.pendingLocalTime = 0;
@@ -369,6 +388,10 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
} }
public onError(): void { public onError(): void {
this.playRequestId++;
this.pendingLocalTime = null;
this.pendingAutoplay = false;
this.seekPlaybackIntent = null;
this.videoLoadError.set(true); this.videoLoadError.set(true);
this.isPlaying.set(false); this.isPlaying.set(false);
} }
@@ -379,11 +402,52 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
public togglePlay(): void { public togglePlay(): void {
const v = this.videoRef?.nativeElement; const v = this.videoRef?.nativeElement;
if (!v) return; if (!v) return;
if (v.paused) { if (this.pendingLocalTime !== null) {
v.play().catch(() => {}); this.pendingAutoplay = !this.isPlaying();
} else { this.isPlaying.set(this.pendingAutoplay);
v.pause(); return;
} }
if (this.seekPlaybackIntent !== null) {
this.seekPlaybackIntent = !this.isPlaying();
this.isPlaying.set(this.seekPlaybackIntent);
return;
}
if (v.paused) {
this.playVideo();
} else {
this.playRequestId++;
v.pause();
this.isPlaying.set(false);
}
}
private playVideo(): void {
const v = this.videoRef?.nativeElement;
if (!v) return;
const requestId = ++this.playRequestId;
v.play().then(() => {
if (requestId === this.playRequestId) this.isPlaying.set(!v.paused && !v.ended);
}).catch(() => {
// A rejected play request can leave paused=false without advancing frames.
// Reset the native state too, so the next click really starts playback.
if (requestId === this.playRequestId) {
v.pause();
this.isPlaying.set(false);
}
});
}
private seekWithinVideo(seconds: number, shouldPlay: boolean): void {
const v = this.videoRef?.nativeElement;
if (!v) return;
this.playRequestId++;
this.seekPlaybackIntent = shouldPlay;
// Cancel any unfinished play request before seeking; resume only once the
// browser has decoded the destination, including after a source switch.
v.pause();
this.isPlaying.set(false);
v.currentTime = seconds;
if (!v.seeking) this.onSeeked();
} }
public restart(): void { public restart(): void {
@@ -421,19 +485,19 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
const target = Math.max(0, Math.min(this.duration(), seconds)); const target = Math.max(0, Math.min(this.duration(), seconds));
const segments = this.agentService.activeVideoSegments(); const segments = this.agentService.activeVideoSegments();
const location = locateTimelineTime(segments, target); const location = locateTimelineTime(segments, target);
const shouldPlay = autoplay || this.pendingAutoplay || (this.seekPlaybackIntent ?? !v.paused);
if (!location) { if (!location) {
v.currentTime = target; this.seekWithinVideo(target, shouldPlay);
if (autoplay) v.play().catch(() => {}); } else if (location.index === this.activeSegmentIndex() && this.pendingLocalTime === null) {
return; this.seekWithinVideo(location.localTime, shouldPlay);
}
const { index, localTime } = location;
if (index === this.activeSegmentIndex()) {
v.currentTime = localTime;
if (autoplay) v.play().catch(() => {});
} else { } else {
this.pendingLocalTime = localTime; this.playRequestId++;
this.pendingAutoplay = autoplay || !v.paused; this.seekPlaybackIntent = null;
this.activeSegmentIndex.set(index); v.pause();
this.isPlaying.set(false);
this.pendingLocalTime = location.localTime;
this.pendingAutoplay = shouldPlay;
this.activeSegmentIndex.set(location.index);
} }
this.currentTime.set(target); this.currentTime.set(target);
} }
@@ -641,6 +705,7 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
} }
public ngOnDestroy(): void { public ngOnDestroy(): void {
this.playRequestId++;
if (this.stepTimer) { if (this.stepTimer) {
clearTimeout(this.stepTimer); clearTimeout(this.stepTimer);
this.stepTimer = null; this.stepTimer = null;
@@ -10,6 +10,12 @@ interface Capabilities {
step_summarizer_enabled: boolean; step_summarizer_enabled: boolean;
screenshot_limit_enabled: boolean; screenshot_limit_enabled: boolean;
screenshot_max_long_edge: number; screenshot_max_long_edge: number;
task_timeout_seconds: number;
observation_timeout_seconds: number;
observation_max_attempts: number;
device_command_timeout_seconds: number;
action_failure_attempts: number;
worker_cleanup_timeout_seconds: number;
} }
@Component({ @Component({
@@ -28,7 +34,19 @@ interface Capabilities {
<span>步骤摘要压缩<small>Flash 模式下在后台生成摘要,减少长任务的上下文占用。</small></span></label> <span>步骤摘要压缩<small>Flash 模式下在后台生成摘要,减少长任务的上下文占用。</small></span></label>
<label><input type="checkbox" role="switch" [(ngModel)]="config.screenshot_limit_enabled" [disabled]="busy() || loading() || !loaded()" /> <label><input type="checkbox" role="switch" [(ngModel)]="config.screenshot_limit_enabled" [disabled]="busy() || loading() || !loaded()" />
<span>限制截图分辨率<small>开启时长边最多 {{ config.screenshot_max_long_edge }} 像素;关闭后使用设备原始分辨率。JSON 配置可覆盖开关和上限。</small></span></label> <span>限制截图分辨率<small>开启时长边最多 {{ config.screenshot_max_long_edge }} 像素;关闭后使用设备原始分辨率。JSON 配置可覆盖开关和上限。</small></span></label>
<button type="button" (click)="save()" [disabled]="busy() || loading() || !loaded()">{{ busy() ? '正在保存…' : '保存功能设置' }}</button> <details class="execution-settings">
<summary>执行保护</summary>
<p>限制故障等待与反复失败;保存后对新任务生效,任务 JSON 可单独覆盖。</p>
<div class="number-fields">
<label>任务总超时(秒)<input type="number" min="30" max="86400" step="1" [(ngModel)]="config.task_timeout_seconds" [disabled]="busy() || !loaded()" /><small>不包含排队和用户暂停时间。</small></label>
<label>屏幕观测超时(秒)<input type="number" min="1" max="300" [(ngModel)]="config.observation_timeout_seconds" [disabled]="busy() || !loaded()" /></label>
<label>观测恢复最多尝试次数<input type="number" min="1" max="5" step="1" [(ngModel)]="config.observation_max_attempts" [disabled]="busy() || !loaded()" /></label>
<label>ADB 命令超时(秒)<input type="number" min="1" max="60" [(ngModel)]="config.device_command_timeout_seconds" [disabled]="busy() || !loaded()" /></label>
<label>连续动作失败次数<input type="number" min="1" max="10" step="1" [(ngModel)]="config.action_failure_attempts" [disabled]="busy() || !loaded()" /><small>先重新观察,再让兜底模型处理当前步骤。</small></label>
<label>任务收尾超时(秒)<input type="number" min="1" max="300" [(ngModel)]="config.worker_cleanup_timeout_seconds" [disabled]="busy() || !loaded()" /><small>已有最终结果但进程未退出时,回收进程以释放队列。</small></label>
</div>
</details>
<button type="button" (click)="save()" [disabled]="busy() || loading() || !loaded() || !valid()">{{ busy() ? '正在保存…' : '保存功能设置' }}</button>
@if (message()) { <p role="status">{{ message() }}</p> } @if (message()) { <p role="status">{{ message() }}</p> }
@if (error()) { <p role="alert" class="error">{{ error() }}</p> } @if (error()) { <p role="alert" class="error">{{ error() }}</p> }
</section> </section>
@@ -38,12 +56,18 @@ interface Capabilities {
label { display: flex; gap: 12px; align-items: flex-start; padding: 14px 0; cursor: pointer; } label { display: flex; gap: 12px; align-items: flex-start; padding: 14px 0; cursor: pointer; }
input { width: 20px; height: 20px; accent-color: #0284c7; flex-shrink: 0; } input { width: 20px; height: 20px; accent-color: #0284c7; flex-shrink: 0; }
small { display: block; margin-top: 4px; } button { padding: 10px 18px; border-radius: 10px; border: 1px solid #38bdf877; background: #0284c7; color: white; font: inherit; cursor: pointer; } small { display: block; margin-top: 4px; } button { padding: 10px 18px; border-radius: 10px; border: 1px solid #38bdf877; background: #0284c7; color: white; font: inherit; cursor: pointer; }
button:disabled { opacity: .5; cursor: default; } .error { color: #be123c; }`] button:disabled { opacity: .5; cursor: default; } .error { color: #be123c; }
.execution-settings { margin: 12px 0 20px; padding: 12px 14px; border: 1px solid #cbd5e1; border-radius: 10px; background: #f8fafc; }
summary { cursor: pointer; font-weight: 600; color: #334155; }
.number-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 16px; }
.number-fields label { display: block; padding: 0; color: #334155; }
.number-fields input { box-sizing: border-box; display: block; width: 100%; height: auto; margin-top: 8px; padding: 9px; border: 1px solid #cbd5e1; border-radius: 8px; font: inherit; }
@media(max-width: 480px) { .number-fields { grid-template-columns: 1fr; } }`]
}) })
export class CapabilitySettingsComponent implements OnInit { export class CapabilitySettingsComponent implements OnInit {
changed = output<Capabilities>(); changed = output<Capabilities>();
private http = inject(HttpClient); private http = inject(HttpClient);
config: Capabilities = { video_enabled: false, recording_enabled: false, outputter_enabled: true, step_summarizer_enabled: true, screenshot_limit_enabled: true, screenshot_max_long_edge: 2048 }; config: Capabilities = { video_enabled: false, recording_enabled: false, outputter_enabled: true, step_summarizer_enabled: true, screenshot_limit_enabled: true, screenshot_max_long_edge: 2048, task_timeout_seconds: 1800, observation_timeout_seconds: 60, observation_max_attempts: 3, device_command_timeout_seconds: 10, action_failure_attempts: 3, worker_cleanup_timeout_seconds: 30 };
busy = signal(false); loading = signal(true); loaded = signal(false); message = signal(''); error = signal(''); busy = signal(false); loading = signal(true); loaded = signal(false); message = signal(''); error = signal('');
ngOnInit(): void { ngOnInit(): void {
this.http.get<Capabilities>('/api/system/capability-settings').pipe(finalize(() => this.loading.set(false))).subscribe({ this.http.get<Capabilities>('/api/system/capability-settings').pipe(finalize(() => this.loading.set(false))).subscribe({
@@ -51,11 +75,20 @@ export class CapabilitySettingsComponent implements OnInit {
}); });
} }
save(): void { save(): void {
if (this.busy() || this.loading() || !this.loaded()) return; if (this.busy() || this.loading() || !this.loaded() || !this.valid()) return;
this.busy.set(true); this.message.set(''); this.error.set(''); this.busy.set(true); this.message.set(''); this.error.set('');
this.http.post<Capabilities>('/api/system/capability-settings', this.config).pipe(finalize(() => this.busy.set(false))).subscribe({ this.http.post<Capabilities>('/api/system/capability-settings', this.config).pipe(finalize(() => this.busy.set(false))).subscribe({
next: config => { this.config = config; this.changed.emit(config); this.message.set('功能设置已保存,新任务将使用此配置。'); }, next: config => { this.config = config; this.changed.emit(config); this.message.set('功能设置已保存,新任务将使用此配置。'); },
error: () => this.error.set('保存失败,请检查服务器连接和 .env 文件权限。') error: () => this.error.set('保存失败,请检查服务器连接和 .env 文件权限。')
}); });
} }
valid(): boolean {
const c = this.config;
return Number.isInteger(c.task_timeout_seconds) && c.task_timeout_seconds >= 30 && c.task_timeout_seconds <= 86400
&& c.observation_timeout_seconds > 0 && c.observation_timeout_seconds <= 300
&& Number.isInteger(c.observation_max_attempts) && c.observation_max_attempts >= 1 && c.observation_max_attempts <= 5
&& c.device_command_timeout_seconds > 0 && c.device_command_timeout_seconds <= 60
&& Number.isInteger(c.action_failure_attempts) && c.action_failure_attempts >= 1 && c.action_failure_attempts <= 10
&& c.worker_cleanup_timeout_seconds > 0 && c.worker_cleanup_timeout_seconds <= 300;
}
} }
@@ -138,7 +138,7 @@ describe('持久化模型与功能设置', () => {
}); });
it('录屏与视频分析独立保存,重新打开后保留各自状态', () => { it('录屏与视频分析独立保存,重新打开后保留各自状态', () => {
const capabilities = { video_enabled: true, recording_enabled: false, outputter_enabled: true, step_summarizer_enabled: false, screenshot_limit_enabled: true, screenshot_max_long_edge: 2048 }; const capabilities = { video_enabled: true, recording_enabled: false, outputter_enabled: true, step_summarizer_enabled: false, screenshot_limit_enabled: true, screenshot_max_long_edge: 2048, task_timeout_seconds: 1800, observation_timeout_seconds: 60, observation_max_attempts: 3, device_command_timeout_seconds: 10, action_failure_attempts: 3, worker_cleanup_timeout_seconds: 30 };
const fixture = TestBed.createComponent(CapabilitySettingsComponent); const fixture = TestBed.createComponent(CapabilitySettingsComponent);
fixture.detectChanges(); fixture.detectChanges();
http.expectOne('/api/system/capability-settings').flush(capabilities); http.expectOne('/api/system/capability-settings').flush(capabilities);
@@ -160,4 +160,34 @@ describe('持久化模型与功能设置', () => {
expect(reopened.componentInstance.config.recording_enabled).toBeTrue(); expect(reopened.componentInstance.config.recording_enabled).toBeTrue();
expect(reopened.componentInstance.config.screenshot_limit_enabled).toBeFalse(); expect(reopened.componentInstance.config.screenshot_limit_enabled).toBeFalse();
}); });
it('执行保护默认折叠,修改时限可保存且拒绝无效数值', async () => {
const capabilities = { video_enabled: false, recording_enabled: false, outputter_enabled: true, step_summarizer_enabled: true, screenshot_limit_enabled: true, screenshot_max_long_edge: 2048, task_timeout_seconds: 1800, observation_timeout_seconds: 60, observation_max_attempts: 3, device_command_timeout_seconds: 10, action_failure_attempts: 3, worker_cleanup_timeout_seconds: 30 };
const fixture = TestBed.createComponent(CapabilitySettingsComponent);
fixture.detectChanges();
http.expectOne('/api/system/capability-settings').flush(capabilities);
fixture.detectChanges();
await fixture.whenStable();
const root: HTMLElement = fixture.nativeElement;
expect(root.querySelector<HTMLDetailsElement>('.execution-settings')!.open).toBeFalse();
root.querySelector<HTMLElement>('summary')!.click();
const timeout = root.querySelector<HTMLInputElement>('input[type="number"]')!;
timeout.value = '3600';
timeout.dispatchEvent(new Event('input'));
fixture.detectChanges();
await fixture.whenStable();
root.querySelector<HTMLButtonElement>('button')!.click();
const request = http.expectOne('/api/system/capability-settings');
expect(request.request.body.task_timeout_seconds).toBe(3600);
expect(request.request.body.observation_timeout_seconds).toBe(60);
request.flush({ ...capabilities, task_timeout_seconds: 3600 });
expect(fixture.componentInstance.message()).toContain('已保存');
timeout.value = '0';
timeout.dispatchEvent(new Event('input'));
fixture.detectChanges();
await fixture.whenStable();
expect(root.querySelector<HTMLButtonElement>('button')!.disabled).toBeTrue();
fixture.componentInstance.save();
http.expectNone('/api/system/capability-settings');
});
}); });
+72 -17
View File
@@ -81,7 +81,7 @@ from artemis.graph.state import State
from artemis.llm.router import ModelEndpoint from artemis.llm.router import ModelEndpoint
from artemis.llm.structured import ParseFailure, parse_structured from artemis.llm.structured import ParseFailure, parse_structured
from artemis.mcp.action_executor import McpActionExecutor from artemis.mcp.action_executor import McpActionExecutor
from artemis.mcp.observation import observe from artemis.mcp.observation import DeviceObservationError, require_observation
from artemis.memory.transcript import PRO_UI_LIST_MARKER, TranscriptLedger, mark_ephemeral from artemis.memory.transcript import PRO_UI_LIST_MARKER, TranscriptLedger, mark_ephemeral
from artemis.services.llm import ( from artemis.services.llm import (
RobustChatModelWrapper, RobustChatModelWrapper,
@@ -380,8 +380,16 @@ class FlashRunner:
blocks.append( blocks.append(
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{img_b64}"}} {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{img_b64}"}}
) )
if xml_list: blocks.append(
blocks.append({"type": "text", "text": f"{PRO_UI_LIST_MARKER}\n{xml_list}"}) {
"type": "text",
"text": f"{PRO_UI_LIST_MARKER}\n"
+ (
xml_list
or "当前没有可用的元素索引。仅根据当前截图定位坐标,禁止使用历史画面的元素编号。"
),
}
)
ephemeral: list[int] = [] ephemeral: list[int] = []
# (text, ephemeral) in tail order. # (text, ephemeral) in tail order.
per_turn: list[tuple[str, bool]] = [(text, True) for text in (notices or [])] per_turn: list[tuple[str, bool]] = [(text, True) for text in (notices or [])]
@@ -604,19 +612,14 @@ class FlashRunner:
post_img_bytes = exec_result.screenshot_bytes post_img_bytes = exec_result.screenshot_bytes
if post_img_bytes or name not in action_names: if post_img_bytes or name not in action_names:
return post_img_bytes return post_img_bytes
try: if state is not None:
obs, screenshot_bytes = await observe(self.ctx, self.controller, settle_ms=0) state.indexed_points = []
except Exception as shot_err: state.indexed_elements = []
logger.warning(f"Failed to capture fallback screenshot in FlashRunner: {shot_err}") state.latest_screenshot = None
return post_img_bytes obs, screenshot_bytes = await require_observation(self.ctx, self.controller, settle_ms=0)
if not obs.ok:
logger.warning(f"Failed to capture fallback screenshot in FlashRunner: {obs.message}")
return post_img_bytes
post_img_bytes = screenshot_bytes post_img_bytes = screenshot_bytes
if not exec_result.ui_elements_text: exec_result.ui_elements_text = obs.elements_text
exec_result.ui_elements_text = obs.elements_text exec_result.screenshot_path = obs.screenshot_path
if not exec_result.screenshot_path and obs.screenshot_path:
exec_result.screenshot_path = obs.screenshot_path
if state is not None: if state is not None:
if obs.hierarchy_ok: if obs.hierarchy_ok:
state.indexed_points = [el["center"] for el in obs.elements] state.indexed_points = [el["center"] for el in obs.elements]
@@ -831,7 +834,7 @@ class FlashRunner:
turn.actions.append((name, exec_result.status, exec_result.text_summary)) turn.actions.append((name, exec_result.status, exec_result.text_summary))
# ⚡ Non-blocking dispatch of objective visual transition summarizer # ⚡ Non-blocking dispatch of objective visual transition summarizer
if self.summarizer and name in action_names: if self.summarizer and name in action_names and post_img_bytes:
action_sequence += 1 action_sequence += 1
self.summarizer.dispatch( self.summarizer.dispatch(
step_number=action_sequence, step_number=action_sequence,
@@ -857,7 +860,7 @@ class FlashRunner:
if post_img_bytes: if post_img_bytes:
pre_screenshot_bytes = post_img_bytes pre_screenshot_bytes = post_img_bytes
if exec_result.ui_elements_text: if exec_result.ui_elements_text is not None:
xml_list = exec_result.ui_elements_text xml_list = exec_result.ui_elements_text
# Helper tools may return multimodal blocks (a step screenshot); # Helper tools may return multimodal blocks (a step screenshot);
@@ -876,6 +879,8 @@ class FlashRunner:
) )
) )
except DeviceObservationError:
raise
except Exception as e: except Exception as e:
logger.error(f"Error executing tool {name}: {e}") logger.error(f"Error executing tool {name}: {e}")
if name in action_names: if name in action_names:
@@ -1047,6 +1052,21 @@ class FlashRunner:
@trace(type="agent", name="FlashRunner") @trace(type="agent", name="FlashRunner")
async def run(self, state: State) -> dict: async def run(self, state: State) -> dict:
try:
return await self._run(state)
except DeviceObservationError as exc:
state.indexed_points = []
state.indexed_elements = []
state.latest_screenshot = None
logger.error(f"Flash 因设备观测失败停止:{exc}")
return {
"status": "failed",
"explanation": str(exc),
"error": str(exc),
"code": "device_observation_failed",
}
async def _run(self, state: State) -> dict:
limit = self.turn_limit limit = self.turn_limit
logger.info( logger.info(
f"Starting Artemis Flash reactive loop for goal: {self.goal}" f"Starting Artemis Flash reactive loop for goal: {self.goal}"
@@ -1073,6 +1093,7 @@ class FlashRunner:
no_tool_limit = settings.ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS no_tool_limit = settings.ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS
fallback_llm = None fallback_llm = None
using_tool_fallback = False using_tool_fallback = False
action_failure_attempts = 0
while limit is None or turns < limit: while limit is None or turns < limit:
control = getattr(self.ctx, "task_control", None) control = getattr(self.ctx, "task_control", None)
@@ -1084,6 +1105,7 @@ class FlashRunner:
if resumed: if resumed:
using_tool_fallback = False using_tool_fallback = False
no_tool_attempts = 0 no_tool_attempts = 0
action_failure_attempts = 0
self._llm = llm self._llm = llm
( (
shot_path, shot_path,
@@ -1157,6 +1179,7 @@ class FlashRunner:
# assembled before the instruction arrived. # assembled before the instruction arrived.
using_tool_fallback = False using_tool_fallback = False
no_tool_attempts = 0 no_tool_attempts = 0
action_failure_attempts = 0
self._llm = llm self._llm = llm
( (
shot_path, shot_path,
@@ -1241,6 +1264,7 @@ class FlashRunner:
fallback_llm = get_llm(self.ctx, name="operator", use_fallback=True) fallback_llm = get_llm(self.ctx, name="operator", use_fallback=True)
using_tool_fallback = True using_tool_fallback = True
no_tool_attempts = 0 no_tool_attempts = 0
action_failure_attempts = 0
logger.warning("Flash 首选模型未调用工具,当前步骤切换到兜底模型") logger.warning("Flash 首选模型未调用工具,当前步骤切换到兜底模型")
pending_notices.append("首选模型连续未调用工具;本步骤改用兜底模型。") pending_notices.append("首选模型连续未调用工具;本步骤改用兜底模型。")
pending_notices.append(_NO_TOOL_CALL_NOTICE) pending_notices.append(_NO_TOOL_CALL_NOTICE)
@@ -1279,6 +1303,37 @@ class FlashRunner:
using_tool_fallback = False using_tool_fallback = False
self._llm = llm self._llm = llm
pending_notices.append("兜底模型已完成本步骤;后续步骤恢复首选模型。") pending_notices.append("兜底模型已完成本步骤;后续步骤恢复首选模型。")
for _, status, _ in turn.actions:
action_failure_attempts = 0 if status == "success" else action_failure_attempts + 1
failure_limit = (
settings.ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS
if using_tool_fallback
else settings.ARTEMIS_ACTION_FAILURE_ATTEMPTS
)
if action_failure_attempts >= failure_limit:
if using_tool_fallback:
final_report = {
"status": "failed",
"explanation": f"兜底模型连续 {action_failure_attempts} 次设备动作失败,已停止任务。最后原因:{turn.result()}",
}
break
# A fresh, valid observation is required before spending any
# fallback model calls. Hardware recovery is not model retry.
(
shot_path,
current_pre_screenshot_bytes,
current_xml_list,
) = await capture_screenshot_and_parse_ui(self.ctx, state, self.controller)
state.latest_screenshot = shot_path
fallback_llm = get_llm(self.ctx, name="operator", use_fallback=True)
using_tool_fallback = True
self._llm = fallback_llm
action_failure_attempts = 0
no_tool_attempts = 0
pending_notices.append(
f"首选模型连续 {settings.ARTEMIS_ACTION_FAILURE_ATTEMPTS} 次设备动作失败。"
f"最后原因:{turn.result()}。已重新观察;本步骤由兜底模型重新定位,勿重复旧动作。"
)
ledger.stage_turn(messages[turn_base:]) ledger.stage_turn(messages[turn_base:])
previous_turn = turn previous_turn = turn
if final_report_from_calls is not None: if final_report_from_calls is not None:
@@ -136,9 +136,14 @@ async def capture_screenshot_and_parse_ui(
Thin adapter over :func:`artemis.mcp.observation.observe` that adds the LangGraph Thin adapter over :func:`artemis.mcp.observation.observe` that adds the LangGraph
``State`` write-back (indexed points/elements) the agents rely on. ``State`` write-back (indexed points/elements) the agents rely on.
""" """
obs, screenshot_bytes = await observe(ctx, controller, settle_ms=0 if skip_settling else 400) from artemis.mcp.observation import require_observation
if not obs.ok:
return None, None, None state.indexed_points = []
state.indexed_elements = []
state.latest_screenshot = None
obs, screenshot_bytes = await require_observation(
ctx, controller, settle_ms=0 if skip_settling else 400
)
if obs.hierarchy_ok: if obs.hierarchy_ok:
state.indexed_points = [el["center"] for el in obs.elements] state.indexed_points = [el["center"] for el in obs.elements]
+7
View File
@@ -108,6 +108,7 @@ class Settings(BaseSettings):
OPENAI_REASONING_EFFORT: str = "high" OPENAI_REASONING_EFFORT: str = "high"
ARTEMIS_CREDENTIAL_TIMEOUT_SECONDS: float = Field(default=12.0, gt=0) ARTEMIS_CREDENTIAL_TIMEOUT_SECONDS: float = Field(default=12.0, gt=0)
ARTEMIS_MODEL_CACHE_TTL_SECONDS: float = Field(default=10.0, ge=0) ARTEMIS_MODEL_CACHE_TTL_SECONDS: float = Field(default=10.0, ge=0)
ARTEMIS_DAEMON_PROBE_TIMEOUT_SECONDS: float = Field(default=2.0, gt=0)
ARTEMIS_VIDEO_TIMEOUT_SECONDS: float = Field(default=120.0, gt=0) ARTEMIS_VIDEO_TIMEOUT_SECONDS: float = Field(default=120.0, gt=0)
ARTEMIS_VIDEO_MAX_RETRIES: int = Field(default=1, ge=0) ARTEMIS_VIDEO_MAX_RETRIES: int = Field(default=1, ge=0)
ARTEMIS_VIDEO_CHUNK_SECONDS: float = Field(default=60.0, gt=0) ARTEMIS_VIDEO_CHUNK_SECONDS: float = Field(default=60.0, gt=0)
@@ -122,6 +123,12 @@ class Settings(BaseSettings):
ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS: int = Field(default=3, ge=1, le=20) ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS: int = Field(default=3, ge=1, le=20)
# Consecutive Flash responses without any tool call before failing the task. # Consecutive Flash responses without any tool call before failing the task.
ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS: int = Field(default=3, ge=1, le=20) ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS: int = Field(default=3, ge=1, le=20)
ARTEMIS_TASK_TIMEOUT_SECONDS: int = Field(default=1800, ge=30, le=86400)
ARTEMIS_OBSERVATION_TIMEOUT_SECONDS: float = Field(default=60, gt=0, le=300)
ARTEMIS_OBSERVATION_MAX_ATTEMPTS: int = Field(default=3, ge=1, le=5)
ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS: float = Field(default=10, gt=0, le=60)
ARTEMIS_ACTION_FAILURE_ATTEMPTS: int = Field(default=3, ge=1, le=10)
ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS: float = Field(default=30, gt=0, le=300)
# Android ADB Connectivity # Android ADB Connectivity
ADB_HOST: str | None = Field(default=DEFAULT_ADB_HOST) ADB_HOST: str | None = Field(default=DEFAULT_ADB_HOST)
+95 -28
View File
@@ -19,6 +19,7 @@ import base64
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
from typing import Any, Literal from typing import Any, Literal
from PIL import Image
from adbutils import AdbClient, AdbDevice from adbutils import AdbClient, AdbDevice
from artemis.clients.ui_automator_client import ( from artemis.clients.ui_automator_client import (
@@ -26,6 +27,7 @@ from artemis.clients.ui_automator_client import (
_parse_hierarchy_xml_to_elements, _parse_hierarchy_xml_to_elements,
) )
from artemis.config.agent import load_agent_config from artemis.config.agent import load_agent_config
from artemis.config.settings import settings
from artemis.config.paths import get_temp_dir from artemis.config.paths import get_temp_dir
from artemis.drivers.base import BaseDeviceDriver, KeyCode, ScreenData, SwipeDirection from artemis.drivers.base import BaseDeviceDriver, KeyCode, ScreenData, SwipeDirection
from artemis.toolchain import find_ffmpeg, find_scrcpy from artemis.toolchain import find_ffmpeg, find_scrcpy
@@ -91,6 +93,7 @@ class AndroidAdbDriver(BaseDeviceDriver):
self._recording_process: asyncio.subprocess.Process | None = None self._recording_process: asyncio.subprocess.Process | None = None
self._recording_output_path: Path | None = None self._recording_output_path: Path | None = None
self._screenshot_config = load_agent_config().screenshot self._screenshot_config = load_agent_config().screenshot
self._screen_capture_task: asyncio.Task[ScreenData] | None = None
@property @property
def device_id(self) -> str: def device_id(self) -> str:
@@ -122,6 +125,24 @@ class AndroidAdbDriver(BaseDeviceDriver):
await asyncio.to_thread(self._ui_adb_client.disconnect) await asyncio.to_thread(self._ui_adb_client.disconnect)
async def get_screen_data(self, skip_settling: bool = False) -> ScreenData: async def get_screen_data(self, skip_settling: bool = False) -> ScreenData:
"""Bound observation latency and share unfinished capture work across retries."""
task = self._screen_capture_task
if task is None or task.done():
task = asyncio.create_task(self._get_screen_data(skip_settling))
# A timed-out caller does not cancel a blocking SDK thread or start
# another concurrent read on the same device. Retrieve late errors.
task.add_done_callback(lambda done: None if done.cancelled() else done.exception())
self._screen_capture_task = task
try:
return await asyncio.wait_for(
asyncio.shield(task), timeout=settings.ARTEMIS_OBSERVATION_TIMEOUT_SECONDS
)
except TimeoutError as exc:
raise TimeoutError(
f"设备屏幕观测超时({settings.ARTEMIS_OBSERVATION_TIMEOUT_SECONDS:g} 秒)"
) from exc
async def _get_screen_data(self, skip_settling: bool = False) -> ScreenData:
"""Captures screenshot and XML hierarchy concurrently.""" """Captures screenshot and XML hierarchy concurrently."""
if not skip_settling: if not skip_settling:
await asyncio.sleep(0.3) await asyncio.sleep(0.3)
@@ -131,11 +152,14 @@ class AndroidAdbDriver(BaseDeviceDriver):
screenshot_base64: str = "" screenshot_base64: str = ""
ui_hierarchy_xml: str | None = None ui_hierarchy_xml: str | None = None
ui_elements: list[dict[str, Any]] = [] ui_elements: list[dict[str, Any]] = []
capture_errors: list[str] = []
# Check if ui_adb_client has direct screen data support # Check if ui_adb_client has direct screen data support
if self._ui_adb_client and hasattr(self._ui_adb_client, "get_screen_data"): if self._ui_adb_client and hasattr(self._ui_adb_client, "get_screen_data"):
try: try:
raw_ui_data = self._ui_adb_client.get_screen_data() raw_ui_data = await asyncio.to_thread(self._ui_adb_client.get_screen_data)
if asyncio.iscoroutine(raw_ui_data):
raw_ui_data = await raw_ui_data
if ( if (
hasattr(raw_ui_data, "base64") hasattr(raw_ui_data, "base64")
and isinstance(raw_ui_data.base64, str) and isinstance(raw_ui_data.base64, str)
@@ -158,11 +182,18 @@ class AndroidAdbDriver(BaseDeviceDriver):
elif isinstance(raw_elements, list): elif isinstance(raw_elements, list):
ui_elements = raw_elements ui_elements = raw_elements
except Exception as e: except Exception as e:
capture_errors.append(str(e))
logger.debug(f"Direct ui_adb_client.get_screen_data error: {e}") logger.debug(f"Direct ui_adb_client.get_screen_data error: {e}")
if not screenshot_base64 or not isinstance(screenshot_base64, str): if not screenshot_base64 or not isinstance(screenshot_base64, str):
try: try:
pil_img = await asyncio.to_thread(self.device.screenshot) raw_image = await asyncio.to_thread(
self.device.shell,
["screencap", "-p"],
encoding=None,
timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS,
)
pil_img = Image.open(BytesIO(raw_image))
if hasattr(pil_img, "save"): if hasattr(pil_img, "save"):
buf = BytesIO() buf = BytesIO()
pil_img.save(buf, format="PNG") pil_img.save(buf, format="PNG")
@@ -170,18 +201,24 @@ class AndroidAdbDriver(BaseDeviceDriver):
self._width, self._height = pil_img.size self._width, self._height = pil_img.size
screenshot_base64 = base64.b64encode(screenshot_bytes).decode("utf-8") screenshot_base64 = base64.b64encode(screenshot_bytes).decode("utf-8")
except Exception as e: except Exception as e:
capture_errors.append(str(e))
logger.warning(f"Device screenshot capture failed on {self._device_id}: {e}") logger.warning(f"Device screenshot capture failed on {self._device_id}: {e}")
if not screenshot_base64 or not isinstance(screenshot_base64, str): if not screenshot_base64 or not isinstance(screenshot_base64, str):
# Fallback for headless testing environments raise RuntimeError(
screenshot_base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" "设备截图失败:ADB 与屏幕辅助服务均未返回有效截图;" + "; ".join(capture_errors)
screenshot_bytes = base64.b64decode(screenshot_base64) )
with Image.open(BytesIO(screenshot_bytes)) as image:
if min(image.size) <= 1:
raise RuntimeError("设备截图无效:收到占位图,禁止基于旧画面继续操作")
image.verify()
# 2. Capture UI Hierarchy via UIAutomator # 2. Capture UI Hierarchy via UIAutomator
if self._ui_adb_client and not ui_elements: if self._ui_adb_client and not ui_elements:
try: try:
if hasattr(self._ui_adb_client, "get_hierarchy"): if hasattr(self._ui_adb_client, "get_hierarchy"):
res = self._ui_adb_client.get_hierarchy() res = await asyncio.to_thread(self._ui_adb_client.get_hierarchy)
hierarchy = await res if asyncio.iscoroutine(res) else res hierarchy = await res if asyncio.iscoroutine(res) else res
if isinstance(hierarchy, str): if isinstance(hierarchy, str):
ui_hierarchy_xml = hierarchy ui_hierarchy_xml = hierarchy
@@ -189,7 +226,7 @@ class AndroidAdbDriver(BaseDeviceDriver):
else: else:
ui_elements = hierarchy ui_elements = hierarchy
elif hasattr(self._ui_adb_client, "get_ui_elements"): elif hasattr(self._ui_adb_client, "get_ui_elements"):
res = self._ui_adb_client.get_ui_elements() res = await asyncio.to_thread(self._ui_adb_client.get_ui_elements)
ui_elements = await res if asyncio.iscoroutine(res) else res ui_elements = await res if asyncio.iscoroutine(res) else res
except Exception as e: except Exception as e:
logger.debug(f"UI hierarchy extraction failed: {e}") logger.debug(f"UI hierarchy extraction failed: {e}")
@@ -208,16 +245,8 @@ class AndroidAdbDriver(BaseDeviceDriver):
screen_width=w, screen_width=w,
screen_height=h, screen_height=h,
) )
clean_b64 = ( clean_b64 = screenshot_base64
screenshot_base64 clean_bytes = screenshot_bytes
if isinstance(screenshot_base64, str) and screenshot_base64
else "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
)
clean_bytes = (
screenshot_bytes
if isinstance(screenshot_bytes, bytes) and screenshot_bytes
else base64.b64decode(clean_b64)
)
if self._screenshot_config.enabled: if self._screenshot_config.enabled:
limited_bytes = limit_screenshot_long_edge( limited_bytes = limit_screenshot_long_edge(
clean_bytes, self._screenshot_config.max_long_edge clean_bytes, self._screenshot_config.max_long_edge
@@ -255,7 +284,9 @@ class AndroidAdbDriver(BaseDeviceDriver):
cmd = f" && sleep {delay_ms / 1000.0:.3f} && ".join(taps) cmd = f" && sleep {delay_ms / 1000.0:.3f} && ".join(taps)
logger.info(f"[ADB] {cmd}") logger.info(f"[ADB] {cmd}")
await asyncio.to_thread(self.device.shell, cmd) await asyncio.to_thread(
self.device.shell, cmd, timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS
)
return True return True
except Exception as e: except Exception as e:
logger.error(f"Tap failed at ({x}, {y}): {e}") logger.error(f"Tap failed at ({x}, {y}): {e}")
@@ -275,7 +306,9 @@ class AndroidAdbDriver(BaseDeviceDriver):
try: try:
cmd = f"input swipe {start_x} {start_y} {end_x} {end_y} {duration_ms}" cmd = f"input swipe {start_x} {start_y} {end_x} {end_y} {duration_ms}"
logger.info(f"[ADB] {cmd}") logger.info(f"[ADB] {cmd}")
await asyncio.to_thread(self.device.shell, cmd) await asyncio.to_thread(
self.device.shell, cmd, timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS
)
return True return True
except Exception as e: except Exception as e:
logger.error(f"Swipe failed from ({start_x},{start_y}) to ({end_x},{end_y}): {e}") logger.error(f"Swipe failed from ({start_x},{start_y}) to ({end_x},{end_y}): {e}")
@@ -324,7 +357,11 @@ class AndroidAdbDriver(BaseDeviceDriver):
"input keyevent 67 && " "input keyevent 67 && "
"input keyevent 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67" "input keyevent 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67"
) )
await asyncio.to_thread(self.device.shell, clear_cmd) await asyncio.to_thread(
self.device.shell,
clear_cmd,
timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS,
)
else: else:
# Append mode: move cursor to the very end of existing text # Append mode: move cursor to the very end of existing text
await asyncio.to_thread( await asyncio.to_thread(
@@ -346,7 +383,11 @@ class AndroidAdbDriver(BaseDeviceDriver):
set_clip_ok = True set_clip_ok = True
if set_clip_ok: if set_clip_ok:
await asyncio.to_thread(self.device.shell, "input keyevent 279") await asyncio.to_thread(
self.device.shell,
"input keyevent 279",
timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS,
)
return True return True
except Exception as e: except Exception as e:
logger.debug(f"Clipboard paste fallback to ADB input: {e}") logger.debug(f"Clipboard paste fallback to ADB input: {e}")
@@ -359,7 +400,11 @@ class AndroidAdbDriver(BaseDeviceDriver):
if "adbkeyboard" in str(default_ime).lower(): if "adbkeyboard" in str(default_ime).lower():
b64_text = base64.b64encode(norm_text.encode("utf-8")).decode("utf-8") b64_text = base64.b64encode(norm_text.encode("utf-8")).decode("utf-8")
broadcast_cmd = f"am broadcast -a ADB_INPUT_B64 --es msg '{b64_text}'" broadcast_cmd = f"am broadcast -a ADB_INPUT_B64 --es msg '{b64_text}'"
await asyncio.to_thread(self.device.shell, broadcast_cmd) await asyncio.to_thread(
self.device.shell,
broadcast_cmd,
timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS,
)
return True return True
except Exception as e: except Exception as e:
# ADBKeyboard probe/broadcast failed; fall through to native input. # ADBKeyboard probe/broadcast failed; fall through to native input.
@@ -370,10 +415,18 @@ class AndroidAdbDriver(BaseDeviceDriver):
for i, line in enumerate(lines): for i, line in enumerate(lines):
if i > 0: if i > 0:
# Send Enter key between lines # Send Enter key between lines
await asyncio.to_thread(self.device.shell, "input keyevent 66") await asyncio.to_thread(
self.device.shell,
"input keyevent 66",
timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS,
)
if line: if line:
escaped = _escape_for_adb_text(line) escaped = _escape_for_adb_text(line)
await asyncio.to_thread(self.device.shell, f"input text {escaped}") await asyncio.to_thread(
self.device.shell,
f"input text {escaped}",
timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS,
)
return True return True
except Exception as e: except Exception as e:
logger.error(f"Input text failed for '{text}': {e}") logger.error(f"Input text failed for '{text}': {e}")
@@ -386,7 +439,11 @@ class AndroidAdbDriver(BaseDeviceDriver):
key_name = str(key).lower().replace("keycode.", "") key_name = str(key).lower().replace("keycode.", "")
keycode_val = ANDROID_KEYCODE_MAP.get(key_name, key) keycode_val = ANDROID_KEYCODE_MAP.get(key_name, key)
await asyncio.to_thread(self.device.shell, f"input keyevent {keycode_val}") await asyncio.to_thread(
self.device.shell,
f"input keyevent {keycode_val}",
timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS,
)
return True return True
except Exception as e: except Exception as e:
logger.error(f"Press key failed for '{key}': {e}") logger.error(f"Press key failed for '{key}': {e}")
@@ -395,7 +452,9 @@ class AndroidAdbDriver(BaseDeviceDriver):
async def launch_app(self, package_name: str) -> bool: async def launch_app(self, package_name: str) -> bool:
try: try:
cmd = f"monkey -p {package_name} -c android.intent.category.LAUNCHER 1" cmd = f"monkey -p {package_name} -c android.intent.category.LAUNCHER 1"
await asyncio.to_thread(self.device.shell, cmd) await asyncio.to_thread(
self.device.shell, cmd, timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS
)
return True return True
except Exception as e: except Exception as e:
logger.error(f"Launch app failed for '{package_name}': {e}") logger.error(f"Launch app failed for '{package_name}': {e}")
@@ -403,7 +462,11 @@ class AndroidAdbDriver(BaseDeviceDriver):
async def stop_app(self, package_name: str) -> bool: async def stop_app(self, package_name: str) -> bool:
try: try:
await asyncio.to_thread(self.device.shell, f"am force-stop {package_name}") await asyncio.to_thread(
self.device.shell,
f"am force-stop {package_name}",
timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS,
)
return True return True
except Exception as e: except Exception as e:
logger.error(f"Stop app failed for '{package_name}': {e}") logger.error(f"Stop app failed for '{package_name}': {e}")
@@ -439,7 +502,11 @@ class AndroidAdbDriver(BaseDeviceDriver):
async def execute_shell(self, command: str, timeout_seconds: float = 15.0) -> str: async def execute_shell(self, command: str, timeout_seconds: float = 15.0) -> str:
try: try:
return await asyncio.wait_for( return await asyncio.wait_for(
asyncio.to_thread(self.device.shell, command), asyncio.to_thread(
self.device.shell,
command,
timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS,
),
timeout=timeout_seconds, timeout=timeout_seconds,
) )
except Exception as e: except Exception as e:
+37 -2
View File
@@ -22,9 +22,12 @@ Validator -- which runs its own UI-change polling -- simply never calls it.
import asyncio import asyncio
import base64 import base64
from io import BytesIO
import time import time
from PIL import Image
from artemis.config import get_temp_dir from artemis.config import get_temp_dir
from artemis.config.settings import settings
from artemis.context import ArtemisContext from artemis.context import ArtemisContext
from artemis.controllers.unified_controller import UnifiedMobileController from artemis.controllers.unified_controller import UnifiedMobileController
from artemis.mcp.action_types import ActionCode, ObserveResult from artemis.mcp.action_types import ActionCode, ObserveResult
@@ -34,7 +37,29 @@ from artemis.utils.visualization import format_minimal_list_with_elements
logger = get_logger(__name__) logger = get_logger(__name__)
__all__ = ["observe"] __all__ = ["observe", "require_observation", "DeviceObservationError"]
class DeviceObservationError(RuntimeError):
"""A live device could not be observed after bounded recovery."""
async def require_observation(
ctx: ArtemisContext, controller: UnifiedMobileController, settle_ms: int = 400
) -> tuple[ObserveResult, bytes | None]:
"""Recover observation without spending model calls on unavailable screens."""
last = "设备未返回有效画面"
for attempt in range(settings.ARTEMIS_OBSERVATION_MAX_ATTEMPTS):
observation, image = await observe(ctx, controller, settle_ms=settle_ms)
if observation.ok:
return observation, image
last = observation.message
logger.warning(
f"设备观测恢复 {attempt + 1}/{settings.ARTEMIS_OBSERVATION_MAX_ATTEMPTS}:{last}"
)
raise DeviceObservationError(
f"设备观测失败(已尝试 {settings.ARTEMIS_OBSERVATION_MAX_ATTEMPTS} 次):{last}"
)
async def observe( async def observe(
@@ -60,9 +85,19 @@ async def observe(
await asyncio.sleep(settle_ms / 1000.0) await asyncio.sleep(settle_ms / 1000.0)
else: else:
logger.info("Retrieving screen data directly (skip settling)...") logger.info("Retrieving screen data directly (skip settling)...")
device_data = await controller.get_screen_data() device_data = await asyncio.wait_for(
controller.get_screen_data(), timeout=settings.ARTEMIS_OBSERVATION_TIMEOUT_SECONDS
)
screenshot_bytes = base64.b64decode(device_data.base64) screenshot_bytes = base64.b64decode(device_data.base64)
with Image.open(BytesIO(screenshot_bytes)) as image:
if min(image.size) <= 1:
raise ValueError("设备截图为占位图,当前画面不可用")
image.verify()
except Exception as e: except Exception as e:
if isinstance(e, TimeoutError):
e = TimeoutError(
f"设备屏幕观测超时({settings.ARTEMIS_OBSERVATION_TIMEOUT_SECONDS:g} 秒)"
)
logger.error(f"Failed to capture screen state: {e}") logger.error(f"Failed to capture screen state: {e}")
return ( return (
ObserveResult( ObserveResult(
+17 -2
View File
@@ -28,6 +28,9 @@ import urllib.error
import urllib.request import urllib.request
from artemis.config.paths import ROOT_DIR from artemis.config.paths import ROOT_DIR
from artemis.config.settings import settings
from artemis.runtime.process_probe import pid_is_alive
from artemis.runtime.server_lifecycle import is_port_in_use, read_server_info
from artemis.utils.logger import get_logger from artemis.utils.logger import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -44,10 +47,11 @@ def is_standalone_forced() -> bool:
def is_daemon_running( def is_daemon_running(
host: str = DEFAULT_DAEMON_HOST, host: str = DEFAULT_DAEMON_HOST,
port: int = DEFAULT_DAEMON_PORT, port: int = DEFAULT_DAEMON_PORT,
timeout: float = 0.3, timeout: float | None = None,
) -> bool: ) -> bool:
"""Fast probe to check if the Artemis Daemon HTTP server is responding.""" """Fast probe to check if the Artemis Daemon HTTP server is responding."""
url = f"http://{host}:{port}/api/system/readiness" timeout = timeout if timeout is not None else settings.ARTEMIS_DAEMON_PROBE_TIMEOUT_SECONDS
url = f"http://{host}:{port}/api/system/server-status"
try: try:
req = urllib.request.Request(url, headers={"User-Agent": "Artemis-Daemon-Probe"}) req = urllib.request.Request(url, headers={"User-Agent": "Artemis-Daemon-Probe"})
with urllib.request.urlopen(req, timeout=timeout) as response: with urllib.request.urlopen(req, timeout=timeout) as response:
@@ -190,6 +194,15 @@ def ensure_daemon_running(
if is_daemon_running(host, port): if is_daemon_running(host, port):
return True, f"http://{host}:{port}" return True, f"http://{host}:{port}"
if is_port_in_use(port, host):
# A slow/busy HTTP loop is not a stopped daemon. Only trust persisted
# metadata belonging to a live process on the requested endpoint.
info = read_server_info() or {}
if info.get("port") == port and info.get("host") == host and pid_is_alive(info.get("pid")):
return True, f"http://{host}:{port}"
logger.warning(f"Port {host}:{port} is occupied; refusing to spawn another daemon")
return False, None
# Auto-spawn Daemon in background # Auto-spawn Daemon in background
spawn_daemon(host, port) spawn_daemon(host, port)
@@ -221,6 +234,7 @@ def submit_task_to_daemon(
conversation_id: str | None = None, conversation_id: str | None = None,
verification_level: str | None = None, verification_level: str | None = None,
explorer_mode: str | None = None, explorer_mode: str | None = None,
runtime_overrides: dict[str, Any] | None = None,
base_url: str | None = None, base_url: str | None = None,
timeout: float = 15.0, timeout: float = 15.0,
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
@@ -246,6 +260,7 @@ def submit_task_to_daemon(
"session_id": session_id, "session_id": session_id,
"ingress": ingress, "ingress": ingress,
"conversation_id": conversation_id, "conversation_id": conversation_id,
"runtime_overrides": runtime_overrides,
} }
try: try:
+30
View File
@@ -0,0 +1,30 @@
"""Execution budgets shared by supervised web and MCP tasks."""
from dataclasses import dataclass, field
import time
from artemis.config.settings import settings
def task_timeout(item: dict, job: dict | None = None, batch: dict | None = None) -> int:
options = item.get("runtime_overrides") or (job or {}).get("case", {}).get("task_config") or {}
return int(
options.get("timeout_seconds")
or (batch or {}).get("timeout_seconds")
or settings.ARTEMIS_TASK_TIMEOUT_SECONDS
)
@dataclass
class ExecutionBudget:
limit: float
elapsed: float = 0
_last: float = field(default_factory=time.monotonic)
_excluded: bool = False
def update(self, *, excluded: bool = False) -> float:
now = time.monotonic()
if not self._excluded:
self.elapsed += max(0, now - self._last)
self._last, self._excluded = now, excluded
return max(0, self.limit - self.elapsed)
+64
View File
@@ -0,0 +1,64 @@
"""Persist useful failure evidence even when a worker must be terminated."""
import json
import os
from pathlib import Path
import uuid
from artemis.config import TRACES_PATH
def read_worker_outcome(session_id: str) -> dict:
"""Read a worker's terminal signal without waiting for hung SDK threads."""
try:
data = json.loads(
(TRACES_PATH / session_id / "run_outcome.json").read_text(encoding="utf-8")
)
if (
isinstance(data, dict)
and data.get("worker_finalized") is True
and data.get("task_status")
in {
"failed",
"completed",
"cancelled",
"interrupted",
}
):
return data
except (OSError, ValueError):
pass # Atomic outcome is absent until the task finalizes.
return {}
def save_failure_result(session_id: str, error: str, *, code: str = "execution_failed") -> None:
if not session_id or not all(c.isalnum() or c in "-_" for c in session_id):
raise ValueError("无效的任务编号")
directory = TRACES_PATH / session_id
directory.mkdir(parents=True, exist_ok=True)
payload: dict = {}
try:
previous = json.loads((directory / "result.json").read_text(encoding="utf-8"))
if isinstance(previous, dict):
payload.update(previous)
except (OSError, ValueError):
pass # Missing/truncated results are expected after a killed worker.
payload.update(
{
"status": "failed",
"task_status": "failed",
"explanation": error,
"error": error,
"code": code,
"worker_finalized": True,
"tests": payload.get("tests") or {},
}
)
for name in ("result.json", "run_outcome.json"):
target = directory / name
temporary = target.with_name(f".{name}.{uuid.uuid4().hex}.tmp")
try:
temporary.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
os.replace(temporary, target)
finally:
temporary.unlink(missing_ok=True)
+21
View File
@@ -32,6 +32,7 @@ import shutil
from shutil import which from shutil import which
import sys import sys
import threading import threading
import time
from types import NoneType from types import NoneType
from typing import Any, TypeVar, overload from typing import Any, TypeVar, overload
import uuid import uuid
@@ -573,6 +574,13 @@ class Agent:
) )
try: try:
if device_lock is not None and os.environ.get("ARTEMIS_CLOUD_MODE") != "1": if device_lock is not None and os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
from artemis.runtime.task_control import control_dir, _write_atomic
if sess_id and os.environ.get("ARTEMIS_TASK_WORKER") == "1":
_write_atomic(
control_dir(str(sess_id)) / "task_control_state.json",
{"status": "queued", "updated_at": time.time(), "pid": os.getpid()},
)
queue_cancel_event = threading.Event() queue_cancel_event = threading.Event()
acquire_task = asyncio.create_task( acquire_task = asyncio.create_task(
asyncio.to_thread( asyncio.to_thread(
@@ -605,6 +613,7 @@ class Agent:
task_control = TaskControl(str(sess_id), device_lock) task_control = TaskControl(str(sess_id), device_lock)
context.task_control = task_control context.task_control = task_control
task_control._state("running")
self._prepare_output_files(task=task) self._prepare_output_files(task=task)
if os.environ.get("ARTEMIS_CLOUD_MODE") != "1": if os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
if self._ui_adb_client is not None: if self._ui_adb_client is not None:
@@ -712,6 +721,14 @@ class Agent:
state=last_state_snapshot, state=last_state_snapshot,
error=err, error=err,
) )
from artemis.runtime.task_result import save_failure_result
if sess_id:
save_failure_result(
str(sess_id),
err,
code=flash_result.get("code", "execution_failed"),
)
if context.data_engine: if context.data_engine:
context.data_engine.end_session("failed") context.data_engine.end_session("failed")
return output return output
@@ -862,6 +879,10 @@ class Agent:
state=last_state_snapshot, state=last_state_snapshot,
error=err, error=err,
) )
from artemis.runtime.task_result import save_failure_result
if sess_id:
save_failure_result(str(sess_id), err)
if context.data_engine: if context.data_engine:
context.data_engine.end_session("failed") context.data_engine.end_session("failed")
+6
View File
@@ -49,3 +49,9 @@ MCP 同步会识别已安装客户端的配置目录,为缺少 ARTEMIS 条目
批次任务在队列中使用“用例编号 + 用例名称”显示,提示词仍是模型实际收到的内容。任务历史展示失败原因、完成时间和总耗时;导出全部资料时以用例编号命名目录。删除非活跃批次会同步清理其任务记录、步骤、截图、录屏及生成的报告,删除后不能通过旧 `request_id` 重复执行。 批次任务在队列中使用“用例编号 + 用例名称”显示,提示词仍是模型实际收到的内容。任务历史展示失败原因、完成时间和总耗时;导出全部资料时以用例编号命名目录。删除非活跃批次会同步清理其任务记录、步骤、截图、录屏及生成的报告,删除后不能通过旧 `request_id` 重复执行。
保存全局配置要求用户明确指令;只读配置不包含密钥,MCP 不接收密钥。密钥仍在网页或 `.env` 中填写。设置对新任务生效,模型请求重试不会触发测试用例自动重跑。历史步骤证据保留,设备动作回放已移除。 保存全局配置要求用户明确指令;只读配置不包含密钥,MCP 不接收密钥。密钥仍在网页或 `.env` 中填写。设置对新任务生效,模型请求重试不会触发测试用例自动重跑。历史步骤证据保留,设备动作回放已移除。
执行保护默认总时限 1800 秒,单任务、批次和 MCP 都适用,排队及用户暂停不计时。设备观测默认 60 秒、最多 3 次,ADB 命令默认 10 秒;观测失败不会继续向模型发送占位图或旧元素索引。Flash 连续 3 次动作失败先重新观察再切换兜底,兜底仍失败则结束任务并保存原因。不能以提高步数或反复重提任务代替设备诊断。
`mobile_run_task(runtime_overrides={"timeout_seconds":1800,"observation_timeout_seconds":60,"observation_max_attempts":3,"device_command_timeout_seconds":10,"action_failure_attempts":3})` 可为单任务覆盖执行保护;其中 `model` 是 API 模型名,外层 `model` 仍是 Flash/Pro 模式。相同字段也支持批次的 `task_config`。全局默认通过网页功能配置或明确授权的 `mobile_config(section="capabilities", changes={"task_timeout_seconds":1800}, persist_defaults=true)` 保存,对后续任务生效,不接受密钥。
任务已经产生最终结果,但 SDK 线程或资源清理仍卡住时,默认最多再等待 30 秒后回收工作进程,避免继续占用队列。全局可用 `ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS` 或功能配置的 `worker_cleanup_timeout_seconds` 调整,任务 JSON 同名字段可覆盖。原有结果与截图保留,未完成保存的录屏尽力恢复。
+9 -3
View File
@@ -20,9 +20,9 @@
4. 上传文件,查看逐行校验结果。所有错误修正后才能提交。 4. 上传文件,查看逐行校验结果。所有错误修正后才能提交。
5. 明确选择设备、执行模式和超时时间,然后点击“确认导入并开始执行”。 5. 明确选择设备、执行模式和超时时间,然后点击“确认导入并开始执行”。
同一批次按顺序派发。默认失败后继续且不自动重试,也可以选择失败即停止。设备不可用、模型暂停或任务超时会暂停后续派发。暂停批次只停止后续派发。单个任务可在安全边界暂停并释放设备;批次下一项可继续,暂停的任务待提交指导或点击继续后在设备队列末尾恢复。运行中提交指导会中断当前模型思考或只读诊断,重新观察画面;已经开始的设备动作会安全结束。网页流程会显示指导的提交与领取状态,仍应检查后续动作是否符合指导。暂停等待和重新排队时间不计入该任务执行时限。 同一批次按顺序派发。默认失败后继续且不自动重试,也可以选择失败即停止。当前任务因设备异常或超时失败后,默认继续后续用例;设置失败即停止时才停止后续派发。暂停批次只停止后续派发。单个任务可在安全边界暂停并释放设备;批次下一项可继续,暂停的任务待提交指导或点击继续后在设备队列末尾恢复。运行中提交指导会中断当前模型思考或只读诊断,重新观察画面;已经开始的设备动作会安全结束。网页流程会显示指导的提交与领取状态,仍应检查后续动作是否符合指导。暂停等待和重新排队时间不计入该任务执行时限。
服务重启后保留批次,等待用户继续未开始的用例。正在执行或暂停的任务标记中断,不自动重做;“复制失败项为新批次”保留原记录并产生新任务编号。 服务启动前先取得监听端口,重复启动不会修改现有任务或服务信息。恢复时核对原任务进程和设备锁:仍存活的任务继续等待其真实结果,退出后补齐报告;已经退出且没有终态的任务标记中断,不自动重做,批次等待用户继续未开始的用例。“复制失败项为新批次”保留原记录并产生新任务编号。任务正常完成后,已失效的“服务中断”提示归档为恢复事件,不再显示为当前失败原因;启动时也会纠正已有完成记录中的此类旧提示。
每个任务结束后生成所选格式报告。批次 ZIP 包含汇总、每项任务的报告和已保存的有序步骤截图;已开启录屏且文件存在时,还会按任务收录录屏片段。失败、跳过和中断任务也会列入。暂停时下载的是当前快照。录屏与 AI 视频分析分别配置,录屏默认关闭;仅开启分析时,录制内容用于本次分析,结束后不保留下载文件。暂停期间不录制其他任务的设备画面。 每个任务结束后生成所选格式报告。批次 ZIP 包含汇总、每项任务的报告和已保存的有序步骤截图;已开启录屏且文件存在时,还会按任务收录录屏片段。失败、跳过和中断任务也会列入。暂停时下载的是当前快照。录屏与 AI 视频分析分别配置,录屏默认关闭;仅开启分析时,录制内容用于本次分析,结束后不保留下载文件。暂停期间不录制其他任务的设备画面。
@@ -34,6 +34,12 @@
## 环境配置 ## 环境配置
`.env` 可配置 `ARTEMIS_BATCH_TASK_TIMEOUT`(默认 1800 秒)、`ARTEMIS_IMPORT_MAX_ROWS`(默认 1000 行)和 `ARTEMIS_IMPORT_MAX_BYTES`(默认 10 MiB)。修改后重启服务。网页的单批次超时设置优先于环境默认值。 `.env` 可配置 `ARTEMIS_TASK_TIMEOUT_SECONDS`(单任务总执行时限,默认 1800 秒)、`ARTEMIS_IMPORT_MAX_ROWS`(默认 1000 行)和 `ARTEMIS_IMPORT_MAX_BYTES`(默认 10 MiB)。修改后重启服务。网页的单批次超时设置优先于环境默认值,每行 JSON 的 `timeout_seconds` 优先于批次。单任务、批次和 MCP 均受总执行时限保护,不计排队和用户暂停时间;即使执行进程卡住,也由父进程终止并保存失败原因。
守护服务通过轻量状态接口探测,`ARTEMIS_DAEMON_PROBE_TIMEOUT_SECONDS` 默认 2 秒。短暂超时且端口仍被存活的原服务占用时复用原服务,不重复启动;未知进程占用端口时明确拒绝覆盖。
网页“功能配置 → 执行保护”及 MCP `mobile_config(section="capabilities")` 可配置总时限、设备观测时限(默认 60 秒)、设备观测次数(默认 3 次)、ADB 命令超时(默认 10 秒)、连续动作失败次数(默认 3 次)。任务 JSON 可用 `observation_timeout_seconds`、`observation_max_attempts`、`device_command_timeout_seconds`、`action_failure_attempts` 覆盖。截图失效或收到占位图时先有限重取,耗尽后结束任务,不把旧画面发给模型;有效截图即使没有 XML 也可按坐标定位。连续动作失败时先刷新画面再尝试兜底,兜底耗尽后结束,不循环重提用例。
批次、任务元数据和报告偏好保存在现有 SQLite 数据库的 `automation_records`、`automation_jobs` 表。全局“清空任务历史”会清除批次与执行元数据;它与上文的单批次删除不同,已导出的文件会保留。 批次、任务元数据和报告偏好保存在现有 SQLite 数据库的 `automation_records`、`automation_jobs` 表。全局“清空任务历史”会清除批次与执行元数据;它与上文的单批次删除不同,已导出的文件会保留。
任务已经产生最终结果,但 SDK 线程或资源清理仍卡住时,默认最多再等待 30 秒后回收工作进程,避免继续占用队列。全局可用 `ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS` 或功能配置的 `worker_cleanup_timeout_seconds` 调整,任务 JSON 同名字段可覆盖。原有结果与截图保留,未完成保存的录屏尽力恢复。
+21
View File
@@ -0,0 +1,21 @@
# ARTEMIS-Eapil 1.3.1
## 修复
- 限制任务执行、设备观察、连续动作失败和资源清理的等待与重试。超时或失败后释放执行资源,让队列继续处理后续任务,避免单个任务长时间反复重试。
- 修复点击录像时间轴关键步骤后,视频暂停但播放按钮状态未同步的问题。播放中跳转后继续播放,暂停时跳转保持暂停,恢复播放只需点击一次。
- 修复服务探测超时后重复启动后台服务,导致仍在执行的任务被误标为“服务中断”的问题。启动时先占用监听端口,再初始化服务和恢复任务;已有服务占用端口时安全退出。
- 恢复任务时保留仍存活的执行进程,并跟踪其最终结果。完成任务会清除已失效的“服务中断”提示并保留恢复记录,真实执行失败和超时原因继续显示。
## 安装与升级
下载附件 `artemis-eapil-1.3.1-source.zip`,解压后运行 `start.bat`;国内网络使用 `start-cn.bat`。源码包包含可继续更新的 Git 仓库,不包含虚拟环境、构建产物、密钥和执行记录。
已有安装可通过启动脚本更新。升级后重启服务、刷新网页,并重新加载 MCP,让各入口使用新版实现。
## 验证
- Python 默认测试:2246 项通过,6 项跳过,8 项未选中;最终恢复逻辑回归测试:65 项通过。
- 前端测试:169 项通过;前端构建、Ruff、质量基线、核心类型检查及 Git 差异检查通过。
- 使用独立进程验证重复启动不会修改现有任务或服务元数据;在浏览器验证录像关键步骤跳转及播放状态。
- 本次验证未运行真实 Android 任务;跨录像片段跳转通过单元测试验证。
+19 -3
View File
@@ -26,7 +26,19 @@ MODEL_FIELDS = {
"tool_fallback_no_progress_attempts", "tool_fallback_no_progress_attempts",
"reasoning_effort", "reasoning_effort",
} }
CAPABILITY_FIELDS = {"video_enabled", "outputter_enabled", "step_summarizer_enabled"} CAPABILITY_FIELDS = {
"video_enabled",
"recording_enabled",
"outputter_enabled",
"step_summarizer_enabled",
"screenshot_limit_enabled",
"task_timeout_seconds",
"observation_timeout_seconds",
"observation_max_attempts",
"device_command_timeout_seconds",
"action_failure_attempts",
"worker_cleanup_timeout_seconds",
}
def _id(value: str | None) -> str: def _id(value: str | None) -> str:
@@ -131,8 +143,12 @@ async def mobile_config(
tool_fallback_reasoning_effort, tool_fallback_max_retries, tool_fallback_reasoning_effort, tool_fallback_max_retries,
tool_fallback_no_progress_attempts. 'default' omits reasoning; tool_fallback_no_progress_attempts. 'default' omits reasoning;
unsupported values fail visibly. unsupported values fail visibly.
capabilities: video_enabled (recording + analysis), outputter_enabled, capabilities: video_enabled and recording_enabled are independent; outputter_enabled,
step_summarizer_enabled. reports: formats=['docx','xlsx'] (at least one). step_summarizer_enabled, screenshot_limit_enabled, task_timeout_seconds (default 1800),
observation_timeout_seconds (60), observation_max_attempts (3),
device_command_timeout_seconds (10), action_failure_attempts (3),
worker_cleanup_timeout_seconds (30).
reports: formats=['docx','xlsx'] (at least one).
API keys are never accepted or returned: use the Web UI or .env for credentials. API keys are never accepted or returned: use the Web UI or .env for credentials.
Saving settings does not change an already running task. Flash/Pro are execution Saving settings does not change an already running task. Flash/Pro are execution
profiles, not model names. Model retries do not repeat device test cases. profiles, not model names. Model retries do not repeat device test cases.
+103
View File
@@ -35,6 +35,10 @@ from artemis.runtime import (
submit_task_to_daemon, submit_task_to_daemon,
trace_store, trace_store,
) )
from artemis.runtime.task_limits import ExecutionBudget, task_timeout
from artemis.runtime.task_control import read_state
from artemis.runtime.task_result import save_failure_result, read_worker_outcome
from artemis.config.settings import settings
# Seconds the spawned runner gets to finish its imports and open its log files # Seconds the spawned runner gets to finish its imports and open its log files
# before the spawn is declared dead. Normal startup creates stdout.log within a # before the spawn is declared dead. Normal startup creates stdout.log within a
@@ -44,6 +48,54 @@ from artemis.runtime import (
SPAWN_WATCHDOG_SECONDS = 60 SPAWN_WATCHDOG_SECONDS = 60
def _watch_execution(proc, trace_id: str, limit: int, cleanup_limit: float | None = None) -> None:
"""Supervise standalone MCP workers without charging device queue or pause time."""
budget = ExecutionBudget(limit)
finalized_at = None
cleanup_limit = cleanup_limit or settings.ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS
while proc.poll() is None:
status = trace_store.read_status(trace_id) or {}
outcome = read_worker_outcome(trace_id)
if outcome and status.get("status") not in {
"completed",
"success",
"failed",
"cancelled",
"interrupted",
}:
try:
trace_store.update_trace_status(
trace_id, outcome["task_status"], error=outcome.get("error")
)
except OSError:
logging.exception("Cannot persist terminal status for %s", trace_id)
if outcome or status.get("status") in {
"completed",
"success",
"failed",
"cancelled",
"interrupted",
}:
finalized_at = finalized_at if finalized_at is not None else time.monotonic()
if time.monotonic() - finalized_at >= cleanup_limit:
_kill_process_tree(proc.pid)
return
time.sleep(0.5)
continue
control_status = read_state(trace_id).get("status")
remaining = budget.update(excluded=control_status in {"queued", "task_paused", "resuming"})
if remaining <= 0:
error = f"任务执行超时({limit} 秒,不含暂停/排队时间)"
_kill_process_tree(proc.pid)
try:
save_failure_result(trace_id, error, code="task_timeout")
trace_store.update_trace_status(trace_id, "failed", error=error)
except OSError:
logging.exception("Cannot persist timeout report for %s", trace_id)
return
time.sleep(0.5)
def _kill_process_tree(pid: int) -> None: def _kill_process_tree(pid: int) -> None:
"""Terminate a spawned runner and its children (the venv shim re-execs python).""" """Terminate a spawned runner and its children (the venv shim re-execs python)."""
if sys.platform == "win32": if sys.platform == "win32":
@@ -212,6 +264,7 @@ def mobile_run_task(
device_serial: str | None = None, device_serial: str | None = None,
verification_level: str | None = None, verification_level: str | None = None,
explorer_mode: str | None = None, explorer_mode: str | None = None,
runtime_overrides: dict[str, Any] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Starts an autonomous mobile UI automation subagent on a connected Android device. """Starts an autonomous mobile UI automation subagent on a connected Android device.
@@ -277,7 +330,25 @@ def mobile_run_task(
by the Operator: `"flash"` (1-shot detection, the default), by the Operator: `"flash"` (1-shot detection, the default),
`"pro"` (3-turn ReAct), `"ultra"` (deep pixel reasoning; slowest). `"pro"` (3-turn ReAct), `"ultra"` (deep pixel reasoning; slowest).
Ignored for Flash. Ignored for Flash.
runtime_overrides: Optional TaskOptions JSON for this task only. Supports
timeout_seconds (total execution, default 1800; excludes queue/pause),
observation_timeout_seconds (60), observation_max_attempts (3),
device_command_timeout_seconds (10), action_failure_attempts (3),
worker_cleanup_timeout_seconds (30),
model (API model name), reasoning_effort, request timeout/retries,
recording and video analysis. Unknown fields and credentials are rejected.
""" """
if runtime_overrides:
from apps.admin_console.services.task_import import TaskOptions
runtime_overrides = TaskOptions.model_validate(runtime_overrides).model_dump(
exclude_none=True
)
model = runtime_overrides.get("profile") or model
device_serial = runtime_overrides.get("device_serial") or device_serial
locked_app_package = runtime_overrides.get("locked_app_package") or locked_app_package
verification_level = runtime_overrides.get("verification_level") or verification_level
explorer_mode = runtime_overrides.get("explorer_mode") or explorer_mode
# 0. Validate and normalize model # 0. Validate and normalize model
if model.lower() not in ("flash", "pro"): if model.lower() not in ("flash", "pro"):
raise ValueError(f"Invalid model '{model}'. Must be either 'Flash' or 'Pro'.") raise ValueError(f"Invalid model '{model}'. Must be either 'Flash' or 'Pro'.")
@@ -325,6 +396,7 @@ def mobile_run_task(
conversation_id=conversation_id, conversation_id=conversation_id,
verification_level=verification_level, verification_level=verification_level,
explorer_mode=explorer_mode, explorer_mode=explorer_mode,
runtime_overrides=runtime_overrides,
base_url=base_url, base_url=base_url,
) )
if resp and resp.get("status") == "rejected": if resp and resp.get("status") == "rejected":
@@ -475,6 +547,26 @@ def mobile_run_task(
env = os.environ.copy() env = os.environ.copy()
env["ARTEMIS_SESSION_ID"] = trace_id env["ARTEMIS_SESSION_ID"] = trace_id
env["ARTEMIS_TASK_INGRESS"] = "mcp" env["ARTEMIS_TASK_INGRESS"] = "mcp"
env["ARTEMIS_TASK_WORKER"] = "1"
for option, variable in {
"model": "OPENAI_MODEL",
"reasoning_effort": "OPENAI_REASONING_EFFORT",
"request_timeout_seconds": "OPENAI_TIMEOUT_SECONDS",
"max_retries": "OPENAI_MAX_RETRIES",
"retry_interval_seconds": "OPENAI_RETRY_INTERVAL_SECONDS",
"record_video": "ARTEMIS_RECORDING_ENABLED",
"analyze_video": "ARTEMIS_VIDEO_ENABLED",
"observation_timeout_seconds": "ARTEMIS_OBSERVATION_TIMEOUT_SECONDS",
"observation_max_attempts": "ARTEMIS_OBSERVATION_MAX_ATTEMPTS",
"device_command_timeout_seconds": "ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS",
"action_failure_attempts": "ARTEMIS_ACTION_FAILURE_ATTEMPTS",
}.items():
if (runtime_overrides or {}).get(option) is not None:
env[variable] = (
str(runtime_overrides[option]).lower()
if isinstance(runtime_overrides[option], bool)
else str(runtime_overrides[option])
)
if device_serial: if device_serial:
env["ADB_DEVICE_SERIAL"] = device_serial env["ADB_DEVICE_SERIAL"] = device_serial
env["ARTEMIS_DEVICE_ID"] = device_serial env["ARTEMIS_DEVICE_ID"] = device_serial
@@ -519,6 +611,17 @@ def mobile_run_task(
# interpreter startup and must be killed instead of squatting on the # interpreter startup and must be killed instead of squatting on the
# queue as 'running' forever. # queue as 'running' forever.
_start_spawn_watchdog(trace_id, proc.pid, queue_ticket, conversation_id) _start_spawn_watchdog(trace_id, proc.pid, queue_ticket, conversation_id)
threading.Thread(
target=_watch_execution,
args=(
proc,
trace_id,
task_timeout({"runtime_overrides": runtime_overrides}),
(runtime_overrides or {}).get("worker_cleanup_timeout_seconds"),
),
daemon=True,
name=f"artemis-task-budget-{trace_id[:8]}",
).start()
trace_dir = trace_store.get_trace_dir(trace_id) trace_dir = trace_store.get_trace_dir(trace_id)
stdout_log_path = os.path.join(trace_dir, "stdout.log") stdout_log_path = os.path.join(trace_dir, "stdout.log")
+1 -1
View File
@@ -14,7 +14,7 @@
[project] [project]
name = "artemis" name = "artemis"
version = "1.3.0" version = "1.3.1"
description = "Autonomous AI Assistant for Mobile Automation" description = "Autonomous AI Assistant for Mobile Automation"
authors = [ authors = [
+6
View File
@@ -35,3 +35,9 @@ description: 使用 ARTEMIS MCP 在 Android 真机或模拟器上完成自然语
- 批次暂停只停止后续派发;当前任务等待指导时用 `mobile_manage_task(action="pause", trace_id=...)`,真正取消用 `stop`。用户明确要求删除批次记录时先 `mobile_batch(action="get")` 核对编号,再用 `delete`;执行中/排队中的批次不能删,删除会清除该批次的任务历史、报告和文件。服务重启后运行/暂停的任务标记中断,不自动重跑。停止可能保留已经产生的业务效果,不声称自动回滚。 - 批次暂停只停止后续派发;当前任务等待指导时用 `mobile_manage_task(action="pause", trace_id=...)`,真正取消用 `stop`。用户明确要求删除批次记录时先 `mobile_batch(action="get")` 核对编号,再用 `delete`;执行中/排队中的批次不能删,删除会清除该批次的任务历史、报告和文件。服务重启后运行/暂停的任务标记中断,不自动重跑。停止可能保留已经产生的业务效果,不声称自动回滚。
需要编写外部可执行测试代码时,先通过 ARTEMIS 实际探索目标应用,确认页面、定位器和转换。优先动态定位,框架支持时才以已验证坐标兜底;在最终脚本中明确等待条件,不将 AI 思考耗时当作确定性等待。 需要编写外部可执行测试代码时,先通过 ARTEMIS 实际探索目标应用,确认页面、定位器和转换。优先动态定位,框架支持时才以已验证坐标兜底;在最终脚本中明确等待条件,不将 AI 思考耗时当作确定性等待。
执行保护默认总时限 1800 秒,单任务、批次和 MCP 都适用,排队及用户暂停不计时。设备观测默认 60 秒、最多 3 次,ADB 命令默认 10 秒;观测失败不会继续向模型发送占位图或旧元素索引。Flash 连续 3 次动作失败先重新观察再切换兜底,兜底仍失败则结束任务并保存原因。不能以提高步数或反复重提任务代替设备诊断。
`mobile_run_task(runtime_overrides={"timeout_seconds":1800,"observation_timeout_seconds":60,"observation_max_attempts":3,"device_command_timeout_seconds":10,"action_failure_attempts":3})` 可为单任务覆盖执行保护;其中 `model` 是 API 模型名,外层 `model` 仍是 Flash/Pro 模式。相同字段也支持批次的 `task_config`。全局默认通过网页功能配置或明确授权的 `mobile_config(section="capabilities", changes={"task_timeout_seconds":1800}, persist_defaults=true)` 保存,对后续任务生效,不接受密钥。
任务已经产生最终结果,但 SDK 线程或资源清理仍卡住时,默认最多再等待 30 秒后回收工作进程,避免继续占用队列。全局可用 `ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS` 或功能配置的 `worker_cleanup_timeout_seconds` 调整,任务 JSON 同名字段可覆盖。原有结果与截图保留,未完成保存的录屏尽力恢复。
+110
View File
@@ -300,6 +300,116 @@ def test_restart_never_repeats_started_work(isolated):
assert isolated.jobs(retry["id"])[0]["session_id"] != job["session_id"] assert isolated.jobs(retry["id"])[0]["session_id"] != job["session_id"]
@pytest.mark.parametrize("liveness_source", ["pid", "device_lock"])
def test_recovery_preserves_live_runner_and_batch(isolated, monkeypatch, liveness_source):
import os
from types import SimpleNamespace
from artemis.runtime import DeviceExecutionLock
batch = service.create_batch(payload())
service.batch_action(batch["id"], "resume")
job = isolated.jobs(batch["id"])[0]
isolated.save_job({**job, "status": "running"})
if liveness_source == "pid":
with sqlite3.connect(isolated.path) as conn:
conn.execute(
"INSERT INTO sessions(session_id,initial_goal,start_time,status,device_info,pid) "
"VALUES (?,?,?,?,?,?)",
(job["session_id"], "goal", 1, "running", "{}", os.getpid()),
)
monkeypatch.setattr(
DeviceExecutionLock,
"get_active_owners",
lambda: (
{"device": SimpleNamespace(session_id=job["session_id"])}
if liveness_source == "device_lock"
else {}
),
)
service.recover_batches()
assert isolated.job(job["session_id"])["status"] == "running"
assert not isolated.job(job["session_id"]).get("error")
assert isolated.get("batch", batch["id"])["status"] == "running"
@pytest.mark.asyncio
async def test_natural_completion_resolves_old_recovery_error_in_job_report_and_history(
client, isolated
):
job = isolated.jobs(service.create_batch(payload())["id"])[0]
message = "服务中断;未完成任务需手动重跑"
isolated.save_job({**job, "status": "interrupted", "error": message})
await service.on_terminal({"session_id": job["session_id"]}, "completed")
saved = isolated.job(job["session_id"])
assert saved["status"] == "completed"
assert not saved.get("error")
assert saved["recovery_events"][0]["message"] == message
assert reports.session_report(job["session_id"])["error"] == ""
assert not client.get("/api/automation/history").json()["items"][0].get("error")
def test_recovery_repairs_completed_legacy_warning_but_preserves_real_failure(isolated):
batch = service.create_batch(payload())
first, second = isolated.jobs(batch["id"])
isolated.save_job({**first, "status": "completed", "error": "服务中断,需手动重跑"})
isolated.save_job({**second, "status": "failed", "error": "任务执行超时(30 秒)"})
assert reports.session_report(first["session_id"])["error"] == ""
service.recover_batches()
assert not isolated.job(first["session_id"]).get("error")
assert "服务中断" in isolated.job(first["session_id"])["recovery_events"][0]["message"]
assert "超时" in isolated.job(second["session_id"])["error"]
@pytest.mark.asyncio
async def test_surviving_worker_is_finalized_when_it_exits_without_resubmission(
isolated, monkeypatch
):
import os
from artemis.runtime import DeviceExecutionLock
batch = service.create_batch(payload())
service.batch_action(batch["id"], "resume")
job = isolated.jobs(batch["id"])[0]
isolated.save_job({**job, "status": "running"})
monkeypatch.setattr(DeviceExecutionLock, "get_active_owners", lambda: {})
with sqlite3.connect(isolated.path) as conn:
conn.execute(
"INSERT INTO sessions(session_id,initial_goal,start_time,status,device_info,pid) "
"VALUES (?,?,?,?,?,?)",
(job["session_id"], "goal", time.time(), "running", "{}", os.getpid()),
)
service.recover_batches()
await service.reconcile_recovered_jobs()
assert isolated.job(job["session_id"])["status"] == "running"
with sqlite3.connect(isolated.path) as conn:
conn.execute(
"UPDATE sessions SET pid=NULL,status='completed' WHERE session_id=?",
(job["session_id"],),
)
await service.reconcile_recovered_jobs()
saved = isolated.job(job["session_id"])
assert saved["status"] == "completed"
assert saved["report_ready"]
assert not saved.get("recovery_waiting")
assert not saved.get("error")
assert len(isolated.jobs(batch["id"])) == 2
assert isolated.jobs(batch["id"])[1]["status"] == "pending"
@pytest.mark.asyncio
async def test_surviving_worker_that_dies_without_result_is_interrupted(isolated, monkeypatch):
from artemis.runtime import DeviceExecutionLock
job = isolated.jobs(service.create_batch(payload())["id"])[0]
isolated.save_job({**job, "status": "running", "recovery_waiting": True})
monkeypatch.setattr(DeviceExecutionLock, "get_active_owners", lambda: {})
await service.reconcile_recovered_jobs()
saved = isolated.job(job["session_id"])
assert saved["status"] == "interrupted"
assert "服务中断" in saved["error"]
assert not saved.get("recovery_waiting")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_batch_admission_idempotent_and_bound_device(isolated, monkeypatch): async def test_batch_admission_idempotent_and_bound_device(isolated, monkeypatch):
from apps.admin_console import server from apps.admin_console import server
@@ -33,6 +33,12 @@ def isolated_settings(monkeypatch, tmp_path):
"ARTEMIS_OUTPUTTER_ENABLED", "ARTEMIS_OUTPUTTER_ENABLED",
"ARTEMIS_STEP_SUMMARIZER_ENABLED", "ARTEMIS_STEP_SUMMARIZER_ENABLED",
"ARTEMIS_SCREENSHOT_LIMIT_ENABLED", "ARTEMIS_SCREENSHOT_LIMIT_ENABLED",
"ARTEMIS_TASK_TIMEOUT_SECONDS",
"ARTEMIS_OBSERVATION_TIMEOUT_SECONDS",
"ARTEMIS_OBSERVATION_MAX_ATTEMPTS",
"ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS",
"ARTEMIS_ACTION_FAILURE_ATTEMPTS",
"ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS",
] ]
for key in keys: for key in keys:
monkeypatch.setattr(settings, key, getattr(settings, key)) monkeypatch.setattr(settings, key, getattr(settings, key))
@@ -202,3 +208,26 @@ def test_responses_payload_has_reasoning_and_no_sdk_retry(monkeypatch, effort):
assert factory.call_args.kwargs["use_responses_api"] is True assert factory.call_args.kwargs["use_responses_api"] is True
ModelFactory.create_model(ModelEndpoint(api_key="test-only", reasoning_effort="default")) ModelFactory.create_model(ModelEndpoint(api_key="test-only", reasoning_effort="default"))
assert "reasoning_effort" not in factory.call_args.kwargs assert "reasoning_effort" not in factory.call_args.kwargs
def test_execution_protection_saves_reload_without_touching_secret(isolated_settings):
result = service.save_capability_settings(
service.CapabilitySettings(
task_timeout_seconds=3600,
observation_timeout_seconds=45,
observation_max_attempts=2,
device_command_timeout_seconds=8,
action_failure_attempts=4,
worker_cleanup_timeout_seconds=15,
)
)
reloaded = Settings(_env_file=isolated_settings)
assert reloaded.ARTEMIS_TASK_TIMEOUT_SECONDS == result.task_timeout_seconds == 3600
assert reloaded.ARTEMIS_OBSERVATION_TIMEOUT_SECONDS == 45
assert reloaded.ARTEMIS_OBSERVATION_MAX_ATTEMPTS == 2
assert reloaded.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS == 8
assert reloaded.ARTEMIS_ACTION_FAILURE_ATTEMPTS == 4
assert reloaded.ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS == 15
assert reloaded.OPENAI_API_KEY is None
assert settings.OPENAI_API_KEY.get_secret_value() == "existing-test-secret"
assert dotenv_values(isolated_settings)["UNRELATED"] == "keep"
@@ -15,6 +15,7 @@
import asyncio import asyncio
from contextlib import suppress from contextlib import suppress
import signal import signal
import socket
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
@@ -23,10 +24,55 @@ import uvicorn
from apps.admin_console.core.state import state from apps.admin_console.core.state import state
from apps.admin_console.routers.tasks import stream_events from apps.admin_console.routers.tasks import stream_events
from apps.admin_console.server import ArtemisUvicornServer, app, on_shutdown from apps.admin_console.server import ArtemisUvicornServer, app, on_shutdown
from apps.admin_console import server as server_module
WINDOWS_FORCE_SIGNAL = getattr(signal, "SIGBREAK", signal.SIGTERM) WINDOWS_FORCE_SIGNAL = getattr(signal, "SIGBREAK", signal.SIGTERM)
@pytest.mark.parametrize("reload", [False, True])
def test_occupied_port_never_initializes_or_overwrites_server_metadata(
tmp_path, monkeypatch, reload
):
from artemis.runtime import server_lifecycle
metadata = tmp_path / "server.json"
metadata.write_text('{"pid":123,"lifecycle_token":"original"}', encoding="utf-8")
monkeypatch.setattr(server_lifecycle, "get_server_info_file", lambda: metadata)
before = metadata.read_bytes()
with (
socket.create_server(("127.0.0.1", 0)) as original,
patch.object(server_module.ArtemisUvicornServer, "run") as run,
patch.object(server_module, "write_server_info") as publish,
):
with pytest.raises(SystemExit, match="1"):
server_module.run_ui_server("127.0.0.1", original.getsockname()[1], reload=reload)
run.assert_not_called()
publish.assert_not_called()
assert metadata.read_bytes() == before
def test_server_holds_port_before_running_lifespan(monkeypatch):
monkeypatch.setattr(state, "port", 0, raising=False)
monkeypatch.setattr(state, "host", "127.0.0.1", raising=False)
observed = []
def run(_server, sockets):
listener = sockets[0]
port = listener.getsockname()[1]
observed.append(listener)
assert state.port == port
with pytest.raises(OSError), socket.create_server(("127.0.0.1", port)):
pass
monkeypatch.setattr(server_module.ArtemisUvicornServer, "run", run)
monkeypatch.setattr(server_module, "clear_server_info", lambda **kwargs: None)
with patch.object(server_module, "write_server_info") as publish:
server_module.run_ui_server("127.0.0.1", 0)
publish.assert_not_called() # Publication belongs to the later lifespan.
assert len(observed) == 1
assert observed[0].fileno() == -1
def _pending_queue_get_tasks(): def _pending_queue_get_tasks():
return { return {
task task
+27 -12
View File
@@ -15,6 +15,9 @@
"""Unit tests for Universal FlashRunner.""" """Unit tests for Universal FlashRunner."""
import base64 import base64
from io import BytesIO
from PIL import Image
from artemis.mcp.observation import DeviceObservationError
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock, patch from unittest.mock import AsyncMock, Mock, patch
import pytest import pytest
@@ -72,9 +75,18 @@ def test_flash_runner_tools_initialization(mock_context):
# --- Post-action screenshot fallback (failed action / unknown tool) ---------------- # --- Post-action screenshot fallback (failed action / unknown tool) ----------------
def _post_image():
buffer = BytesIO()
Image.new("RGB", (64, 64), "white").save(buffer, format="PNG")
return buffer.getvalue()
_POST_IMG = _post_image()
def _screen(elements): def _screen(elements):
return SimpleNamespace( return SimpleNamespace(
base64=base64.b64encode(b"POST-IMG").decode("ascii"), base64=base64.b64encode(_POST_IMG).decode("ascii"),
elements=elements, elements=elements,
width=1080, width=1080,
height=2400, height=2400,
@@ -120,7 +132,7 @@ async def test_fallback_observation_renders_the_indexed_ui_list(mock_context, tm
exec_result, "click", frozenset({"click"}), state exec_result, "click", frozenset({"click"}), state
) )
assert post == b"POST-IMG" assert post == _POST_IMG
assert isinstance(exec_result.ui_elements_text, str) assert isinstance(exec_result.ui_elements_text, str)
assert exec_result.ui_elements_text == "[1] Text: 'Settings' | Bounds: [37,50][962,112]" assert exec_result.ui_elements_text == "[1] Text: 'Settings' | Bounds: [37,50][962,112]"
assert "{'text'" not in exec_result.ui_elements_text assert "{'text'" not in exec_result.ui_elements_text
@@ -154,8 +166,8 @@ async def test_fallback_observation_is_skipped_for_non_action_tools(mock_context
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_fallback_observation_keeps_the_executors_ui_list(mock_context, tmp_path): async def test_fallback_observation_replaces_stale_executor_ui_list(mock_context, tmp_path):
"""An action that reported a UI list but no screenshot keeps its own list.""" """Fresh screenshots and element lists must describe the same observation."""
with patch("artemis.controllers.unified_controller.get_driver"): with patch("artemis.controllers.unified_controller.get_driver"):
runner = FlashRunner(mock_context, goal="g") runner = FlashRunner(mock_context, goal="g")
runner.controller = Mock() runner.controller = Mock()
@@ -175,12 +187,13 @@ async def test_fallback_observation_keeps_the_executors_ui_list(mock_context, tm
exec_result, "click", frozenset({"click"}), None exec_result, "click", frozenset({"click"}), None
) )
assert post == b"POST-IMG" assert post == _POST_IMG
assert exec_result.ui_elements_text == "[1] Text: 'Mine' | Bounds: [0,0][10,10]" assert "Other" in exec_result.ui_elements_text
assert "Mine" not in exec_result.ui_elements_text
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_fallback_observation_failure_is_contained(mock_context): async def test_fallback_observation_failure_stops_after_bounded_recovery(mock_context):
with patch("artemis.controllers.unified_controller.get_driver"): with patch("artemis.controllers.unified_controller.get_driver"):
runner = FlashRunner(mock_context, goal="g") runner = FlashRunner(mock_context, goal="g")
runner.controller = Mock() runner.controller = Mock()
@@ -189,11 +202,13 @@ async def test_fallback_observation_failure_is_contained(mock_context):
state.latest_screenshot = "old.jpg" state.latest_screenshot = "old.jpg"
exec_result = _failed_click() exec_result = _failed_click()
post = await runner._capture_post_screenshot(exec_result, "click", frozenset({"click"}), state) with patch("artemis.mcp.observation.settings.ARTEMIS_OBSERVATION_MAX_ATTEMPTS", 3):
with pytest.raises(DeviceObservationError, match="adb gone"):
assert post is None await runner._capture_post_screenshot(exec_result, "click", frozenset({"click"}), state)
assert exec_result.ui_elements_text is None assert runner.controller.get_screen_data.await_count == 3
assert state.latest_screenshot == "old.jpg" assert state.latest_screenshot is None
assert state.indexed_elements == []
assert state.indexed_points == []
def test_flash_runner_executor_follows_the_flash_explorer_tier(mock_context): def test_flash_runner_executor_follows_the_flash_explorer_tier(mock_context):
+94 -1
View File
@@ -213,7 +213,8 @@ def test_observation_tail_has_pro_shape(mock_context):
texts = [b["text"] for b in later.content] texts = [b["text"] for b in later.content]
assert OBSERVATION_HEADER_RE.match(texts[0]) assert OBSERVATION_HEADER_RE.match(texts[0])
assert "objective" not in "".join(texts).lower() assert "objective" not in "".join(texts).lower()
assert texts[1:] == [ assert "当前没有可用的元素索引" in texts[1]
assert texts[2:] == [
"nudge", "nudge",
"[INJECTED]", "[INJECTED]",
'User instruction: "stop"', 'User instruction: "stop"',
@@ -408,6 +409,98 @@ def _report(status="completed", tc_id="tc-report", **extra):
_INITIAL_OBSERVATION = ("shot0.png", b"IMG0", "[1] Settings") _INITIAL_OBSERVATION = ("shot0.png", b"IMG0", "[1] Settings")
@pytest.mark.asyncio
@pytest.mark.parametrize("fallback_succeeds", [True, False])
async def test_failed_actions_have_finite_fallback_and_restore_primary(
mock_context, monkeypatch, fallback_succeeds
):
monkeypatch.setattr("artemis.agents.flash.runner.settings.ARTEMIS_ACTION_FAILURE_ATTEMPTS", 3)
monkeypatch.setattr(
"artemis.agents.flash.runner.settings.ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS", 3
)
action_count = 4 if fallback_succeeds else 6
responses = [
AIMessage(
content="try", tool_calls=[{"name": "click", "args": {"index": 1}, "id": f"tc-{i}"}]
)
for i in range(action_count)
]
if fallback_succeeds:
responses.append(_report())
with patch("artemis.controllers.unified_controller.get_driver"):
runner = _make_runner(mock_context, responses)
fallback = Mock()
count = 0
def execute(name, args, tc_id, state, **kwargs):
nonlocal count
count += 1
result = _exec_result(tc_id, name, b"current-screen")
if count <= 3 or not fallback_succeeds:
result.status = "error"
result.text_summary = "目标不存在"
return result
runner.executor.execute = AsyncMock(side_effect=execute)
with (
patch(
"artemis.agents.flash.runner.capture_screenshot_and_parse_ui",
AsyncMock(return_value=_INITIAL_OBSERVATION),
) as observe,
patch("artemis.agents.flash.runner.get_llm", return_value=fallback),
):
outcome = await runner.run(State(initial_goal="Open Settings"))
primary = runner._init_llm.return_value
assert all(call["llm"] is primary for call in runner.calls[:3])
assert runner.calls[3]["llm"] is fallback
assert observe.await_count == 2 # initial + fresh observation before fallback
if fallback_succeeds:
assert outcome["status"] == "completed"
assert runner.calls[-1]["llm"] is primary
else:
assert outcome["status"] == "failed"
assert "兜底模型连续 3" in outcome["explanation"]
assert "目标不存在" in outcome["explanation"]
assert len(runner.calls) == 6
@pytest.mark.asyncio
async def test_empty_current_ui_clears_previous_turn_element_list(mock_context):
response = AIMessage(
content="tap", tool_calls=[{"name": "click", "args": {"index": 1}, "id": "tc"}]
)
with patch("artemis.controllers.unified_controller.get_driver"):
runner = _make_runner(mock_context, [response, _report()])
runner.executor.execute = AsyncMock(
return_value=_exec_result("tc", "click", b"fresh-screen", ui="")
)
with patch(
"artemis.agents.flash.runner.capture_screenshot_and_parse_ui",
AsyncMock(return_value=_INITIAL_OBSERVATION),
):
await runner.run(State(initial_goal="Open Settings"))
tail = runner.calls[-1]["messages"][-1]
text = "\n".join(block.get("text", "") for block in tail.content)
assert "当前没有可用的元素索引" in text
assert "[1] Settings" not in text
@pytest.mark.asyncio
async def test_unavailable_screen_ends_before_any_model_call(mock_context):
from artemis.mcp.observation import DeviceObservationError
with patch("artemis.controllers.unified_controller.get_driver"):
runner = _make_runner(mock_context, [])
with patch(
"artemis.agents.flash.runner.capture_screenshot_and_parse_ui",
AsyncMock(side_effect=DeviceObservationError("设备断连")),
):
outcome = await runner.run(State(initial_goal="Open Settings"))
assert outcome["code"] == "device_observation_failed"
assert outcome["error"] == "设备断连"
runner._invoke_model.assert_not_awaited()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_run_builds_prompt_from_ledger_with_session_offsets(mock_context): async def test_run_builds_prompt_from_ledger_with_session_offsets(mock_context):
"""Turn 2 must see: the static system prefix, the committed turn 1 (its """Turn 2 must see: the static system prefix, the committed turn 1 (its
+12 -1
View File
@@ -33,6 +33,17 @@ class DummyState:
self.operator_replan_reason = operator_replan_reason self.operator_replan_reason = operator_replan_reason
import base64
from io import BytesIO
from PIL import Image
def _valid_screen_base64():
buffer = BytesIO()
Image.new("RGB", (64, 64), "white").save(buffer, format="PNG")
return base64.b64encode(buffer.getvalue()).decode("ascii")
@pytest.fixture @pytest.fixture
def mock_context(): def mock_context():
ctx = Mock(spec=ArtemisContext) ctx = Mock(spec=ArtemisContext)
@@ -41,7 +52,7 @@ def mock_context():
ctx.adb_client = Mock() ctx.adb_client = Mock()
ctx.ui_adb_client = Mock() ctx.ui_adb_client = Mock()
mock_screen_data = Mock() mock_screen_data = Mock()
mock_screen_data.base64 = "YmFzZTY0" mock_screen_data.base64 = _valid_screen_base64()
mock_screen_data.elements = [] mock_screen_data.elements = []
mock_screen_data.width = 1080 mock_screen_data.width = 1080
mock_screen_data.height = 2400 mock_screen_data.height = 2400
+37
View File
@@ -93,6 +93,7 @@ def test_ensure_daemon_running_standalone_override(monkeypatch):
def test_ensure_daemon_running_auto_spawns(): def test_ensure_daemon_running_auto_spawns():
with ( with (
patch("artemis.runtime.daemon_client.is_daemon_running", side_effect=[False, True]), patch("artemis.runtime.daemon_client.is_daemon_running", side_effect=[False, True]),
patch("artemis.runtime.daemon_client.is_port_in_use", return_value=False),
patch("artemis.runtime.daemon_client.spawn_daemon") as mock_spawn, patch("artemis.runtime.daemon_client.spawn_daemon") as mock_spawn,
): ):
ok, base_url = ensure_daemon_running(timeout=1.0, wait_ready=True) ok, base_url = ensure_daemon_running(timeout=1.0, wait_ready=True)
@@ -101,6 +102,42 @@ def test_ensure_daemon_running_auto_spawns():
mock_spawn.assert_called_once() mock_spawn.assert_called_once()
def test_busy_live_daemon_is_reused_without_spawning():
with (
patch("artemis.runtime.daemon_client.is_daemon_running", return_value=False),
patch("artemis.runtime.daemon_client.is_port_in_use", return_value=True),
patch(
"artemis.runtime.daemon_client.read_server_info",
return_value={"host": "127.0.0.1", "port": 8000, "pid": 12345},
),
patch("artemis.runtime.daemon_client.pid_is_alive", return_value=True),
patch("artemis.runtime.daemon_client.spawn_daemon") as spawn,
):
assert ensure_daemon_running() == (True, "http://127.0.0.1:8000")
spawn.assert_not_called()
def test_unknown_port_owner_is_not_overwritten():
with (
patch("artemis.runtime.daemon_client.is_daemon_running", return_value=False),
patch("artemis.runtime.daemon_client.is_port_in_use", return_value=True),
patch("artemis.runtime.daemon_client.read_server_info", return_value=None),
patch("artemis.runtime.daemon_client.spawn_daemon") as spawn,
):
assert ensure_daemon_running() == (False, None)
spawn.assert_not_called()
def test_liveness_probe_uses_lightweight_endpoint_and_configured_timeout(monkeypatch):
from artemis.config.settings import settings
monkeypatch.setattr(settings, "ARTEMIS_DAEMON_PROBE_TIMEOUT_SECONDS", 7.0)
with patch("urllib.request.urlopen", return_value=_daemon_ok_response(b"{}")) as probe:
assert is_daemon_running()
assert probe.call_args.args[0].full_url.endswith("/api/system/server-status")
assert probe.call_args.kwargs["timeout"] == 7.0
def test_submit_task_to_daemon(): def test_submit_task_to_daemon():
mock_resp = MagicMock() mock_resp = MagicMock()
mock_resp.status = 200 mock_resp.status = 200
+50 -6
View File
@@ -14,7 +14,9 @@
"""Unit tests for Unified Base Device Driver and implementations.""" """Unit tests for Unified Base Device Driver and implementations."""
import asyncio
import base64 import base64
import threading
from io import BytesIO from io import BytesIO
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
@@ -36,6 +38,48 @@ _ONE_PIXEL_PNG = base64.b64encode(
).decode("ascii") ).decode("ascii")
def _valid_png():
buffer = BytesIO()
Image.new("RGB", (64, 64), "white").save(buffer, format="PNG")
return base64.b64encode(buffer.getvalue()).decode("ascii")
_VALID_PNG = _valid_png()
@pytest.mark.asyncio
async def test_android_driver_rejects_placeholder_without_returning_stale_screen():
client = MagicMock()
client.get_screen_data.return_value = SimpleNamespace(base64=_ONE_PIXEL_PNG)
driver = AndroidAdbDriver("test-device", MagicMock(), ui_adb_client=client)
with pytest.raises(RuntimeError, match="占位图"):
await driver.get_screen_data(skip_settling=True)
@pytest.mark.asyncio
async def test_screen_capture_timeout_does_not_start_overlapping_sdk_calls(monkeypatch):
released = threading.Event()
client = MagicMock()
def blocked_capture():
released.wait(2)
return SimpleNamespace(base64=_VALID_PNG, width=64, height=64, elements=[])
client.get_screen_data.side_effect = blocked_capture
driver = AndroidAdbDriver("test-device", MagicMock(), ui_adb_client=client)
monkeypatch.setattr(
"artemis.drivers.android.adb_driver.settings.ARTEMIS_OBSERVATION_TIMEOUT_SECONDS", 0.02
)
try:
for _ in range(3):
with pytest.raises(TimeoutError, match="观测超时"):
await driver.get_screen_data(skip_settling=True)
assert client.get_screen_data.call_count == 1
finally:
released.set()
await asyncio.wait_for(driver._screen_capture_task, 2)
def test_screenshot_limit_keeps_small_and_invalid_images_unchanged(): def test_screenshot_limit_keeps_small_and_invalid_images_unchanged():
small_image = base64.b64decode(_ONE_PIXEL_PNG) small_image = base64.b64decode(_ONE_PIXEL_PNG)
assert limit_screenshot_long_edge(small_image, 2048) is small_image assert limit_screenshot_long_edge(small_image, 2048) is small_image
@@ -100,16 +144,16 @@ async def test_android_driver_with_mock_adb():
# Test tap # Test tap
await driver.tap(540, 1200) await driver.tap(540, 1200)
mock_adb_device.shell.assert_called_with("input tap 540 1200") mock_adb_device.shell.assert_called_with("input tap 540 1200", timeout=10.0)
# Test multi-tap # Test multi-tap
await driver.tap(540, 1200, times=3, delay_ms=100) await driver.tap(540, 1200, times=3, delay_ms=100)
expected_multi = "input tap 540 1200 && sleep 0.100 && input tap 540 1200 && sleep 0.100 && input tap 540 1200" expected_multi = "input tap 540 1200 && sleep 0.100 && input tap 540 1200 && sleep 0.100 && input tap 540 1200"
mock_adb_device.shell.assert_called_with(expected_multi) mock_adb_device.shell.assert_called_with(expected_multi, timeout=10.0)
# Test press key # Test press key
await driver.press_key(KeyCode.BACK) await driver.press_key(KeyCode.BACK)
mock_adb_device.shell.assert_called_with("input keyevent 4") mock_adb_device.shell.assert_called_with("input keyevent 4", timeout=10.0)
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -132,7 +176,7 @@ async def test_android_driver_preserves_elements_from_combined_screen_data():
}, },
] ]
mock_ui_client.get_screen_data.return_value = SimpleNamespace( mock_ui_client.get_screen_data.return_value = SimpleNamespace(
base64=_ONE_PIXEL_PNG, base64=_VALID_PNG,
hierarchy_xml="<hierarchy />", hierarchy_xml="<hierarchy />",
elements=expected_elements, elements=expected_elements,
width=1080, width=1080,
@@ -188,7 +232,7 @@ async def test_android_driver_parses_xml_hierarchy_fallback():
mock_adb_client = MagicMock() mock_adb_client = MagicMock()
mock_ui_client = MagicMock() mock_ui_client = MagicMock()
mock_ui_client.get_screen_data.return_value = SimpleNamespace( mock_ui_client.get_screen_data.return_value = SimpleNamespace(
base64=_ONE_PIXEL_PNG, base64=_VALID_PNG,
width=1080, width=1080,
height=2400, height=2400,
) )
@@ -216,7 +260,7 @@ async def test_find_element_prefers_fresh_bounds_over_stale_center():
driver.get_screen_data = AsyncMock( driver.get_screen_data = AsyncMock(
return_value=ScreenData( return_value=ScreenData(
screenshot_bytes=base64.b64decode(_ONE_PIXEL_PNG), screenshot_bytes=base64.b64decode(_ONE_PIXEL_PNG),
screenshot_base64=_ONE_PIXEL_PNG, screenshot_base64=_VALID_PNG,
ui_elements=[ ui_elements=[
{ {
"resource_id": "app:id/digit_4", "resource_id": "app:id/digit_4",
+194
View File
@@ -0,0 +1,194 @@
"""Task deadlines must apply to every ingress and exclude waits."""
import json
from unittest.mock import AsyncMock, Mock
import pytest
from artemis.runtime.task_limits import ExecutionBudget, task_timeout
from artemis.runtime.task_result import save_failure_result
def test_deadline_precedence_and_single_task_default(monkeypatch):
monkeypatch.setattr("artemis.runtime.task_limits.settings.ARTEMIS_TASK_TIMEOUT_SECONDS", 1800)
assert task_timeout({}) == 1800
job = {"case": {"task_config": {"timeout_seconds": 120}}}
assert task_timeout({}, job, {"timeout_seconds": 300}) == 120
assert task_timeout({"runtime_overrides": {"timeout_seconds": 60}}, job) == 60
assert task_timeout({}, {}, {"timeout_seconds": 300}) == 300
def test_budget_excludes_device_queue_and_user_pause(monkeypatch):
clock = Mock(return_value=0.0)
monkeypatch.setattr("artemis.runtime.task_limits.time.monotonic", clock)
budget = ExecutionBudget(30, _last=0)
budget.update(excluded=True)
clock.return_value = 3600
assert budget.update(excluded=False) == 30
clock.return_value = 3610
assert budget.update(excluded=True) == 20
clock.return_value = 7200
assert budget.update(excluded=False) == 20
clock.return_value = 7221
assert budget.update() == 0
def test_failure_report_replaces_empty_result_atomically(tmp_path, monkeypatch):
monkeypatch.setattr("artemis.runtime.task_result.TRACES_PATH", tmp_path)
directory = tmp_path / "test-task"
directory.mkdir()
(directory / "result.json").touch()
save_failure_result("test-task", "设备截图失败:ADB gone", code="device_observation_failed")
for name in ("result.json", "run_outcome.json"):
payload = json.loads((directory / name).read_text(encoding="utf-8"))
assert payload["task_status"] == "failed"
assert "ADB gone" in payload["error"]
assert not list(directory.glob("*.tmp"))
(directory / "result.json").write_text(
'{"tests":{"checkpoint":{"passed":false}}}', encoding="utf-8"
)
save_failure_result("test-task", "任务超时", code="task_timeout")
payload = json.loads((directory / "result.json").read_text(encoding="utf-8"))
assert payload["tests"]["checkpoint"]["passed"] is False
def test_checker_outcome_cannot_trigger_worker_cleanup(tmp_path, monkeypatch):
from artemis.runtime.task_result import read_worker_outcome
monkeypatch.setattr("artemis.runtime.task_result.TRACES_PATH", tmp_path)
directory = tmp_path / "checking"
directory.mkdir()
for status in ("failed", "completed"):
(directory / "run_outcome.json").write_text(
json.dumps({"task_status": status}), encoding="utf-8"
)
assert read_worker_outcome("checking") == {}
save_failure_result("checking", "设备故障")
assert read_worker_outcome("checking")["worker_finalized"] is True
@pytest.mark.asyncio
async def test_single_task_hung_worker_is_killed_and_records_timeout(tmp_path, monkeypatch):
import asyncio
from apps.admin_console.services.task_queue_service import TaskQueueService
monkeypatch.setattr("artemis.runtime.task_result.TRACES_PATH", tmp_path)
monkeypatch.setattr(
"apps.admin_console.services.automation_store.store", Mock(job=Mock(return_value=None))
)
monkeypatch.setattr("artemis.runtime.task_limits.task_timeout", lambda *args: 0)
monkeypatch.setattr("artemis.runtime.task_control.read_state", lambda *args: {})
monkeypatch.setattr(TaskQueueService, "_sync_control_states", lambda: None)
stopped = asyncio.Event()
async def wait(proc):
await stopped.wait()
return -9
kill = AsyncMock(side_effect=lambda proc: stopped.set())
monkeypatch.setattr(TaskQueueService, "_wait_for_worker_process", wait)
monkeypatch.setattr(TaskQueueService, "_terminate_worker_process", kill)
item = {"session_id": "hung-single"}
assert (
await asyncio.wait_for(
TaskQueueService._await_worker_with_budget(Mock(), item, "hung-single"), 2
)
== -9
)
kill.assert_awaited_once()
assert item["timed_out"]
result = json.loads((tmp_path / "hung-single" / "result.json").read_text(encoding="utf-8"))
assert result["code"] == "task_timeout"
assert "不含暂停/排队" in result["error"]
def test_standalone_mcp_worker_deadline_kills_before_report_write(monkeypatch):
from mcp_server.tools import task_runner
proc = Mock(pid=999999, poll=Mock(return_value=None))
monkeypatch.setattr(task_runner.trace_store, "read_status", lambda *_: {"status": "running"})
monkeypatch.setattr(task_runner, "read_state", lambda *_: {"status": "running"})
monkeypatch.setattr(
task_runner.DeviceExecutionLock,
"get_active_owners",
lambda: {"device": Mock(session_id="mcp-hung")},
)
events = []
monkeypatch.setattr(task_runner, "_kill_process_tree", lambda *_: events.append("kill"))
def fail_report(*args, **kwargs):
events.append("report")
raise OSError("disk full")
monkeypatch.setattr(task_runner, "save_failure_result", fail_report)
task_runner._watch_execution(proc, "mcp-hung", 0)
assert events == ["kill", "report"]
def test_mcp_finalized_worker_is_reclaimed_without_overwriting_device_failure(monkeypatch):
from mcp_server.tools import task_runner
outcome = {"task_status": "failed", "error": "设备断连", "code": "device_observation_failed"}
monkeypatch.setattr(task_runner, "read_worker_outcome", lambda _: outcome)
monkeypatch.setattr(task_runner.trace_store, "read_status", lambda _: {"status": "running"})
updated = Mock()
monkeypatch.setattr(task_runner.trace_store, "update_trace_status", updated)
kill = Mock()
monkeypatch.setattr(task_runner, "_kill_process_tree", kill)
clock = Mock(side_effect=[0.0, 0.1])
monkeypatch.setattr(task_runner.time, "monotonic", clock)
task_runner._watch_execution(
Mock(pid=123, poll=Mock(return_value=None)), "test-mcp", 1800, cleanup_limit=0.01
)
kill.assert_called_once_with(123)
updated.assert_called_once_with("test-mcp", "failed", error="设备断连")
@pytest.mark.asyncio
@pytest.mark.parametrize("finalized", [False, True])
async def test_real_hung_process_stops_and_next_worker_can_complete(
tmp_path, monkeypatch, finalized
):
import asyncio
import sys
from apps.admin_console.services.task_queue_service import TaskQueueService
monkeypatch.setattr("artemis.runtime.task_result.TRACES_PATH", tmp_path)
monkeypatch.setattr(
"apps.admin_console.services.automation_store.store", Mock(job=Mock(return_value=None))
)
monkeypatch.setattr(
"artemis.runtime.task_limits.task_timeout",
lambda item, *args: 0 if item["session_id"] == "hung" and not finalized else 30,
)
monkeypatch.setattr("artemis.runtime.task_control.read_state", lambda *args: {})
monkeypatch.setattr(TaskQueueService, "_sync_control_states", lambda: None)
item = {"session_id": "hung", "runtime_overrides": {"worker_cleanup_timeout_seconds": 0.01}}
if finalized:
save_failure_result("hung", "设备截图失效", code="device_observation_failed")
hung = await asyncio.create_subprocess_exec(
sys.executable, "-c", "import time; time.sleep(3600)"
)
try:
code = await asyncio.wait_for(
TaskQueueService._await_worker_with_budget(hung, item, "hung"), 10
)
assert code != 0
assert hung.returncode is not None
result = json.loads((tmp_path / "hung" / "result.json").read_text(encoding="utf-8"))
assert result["code"] == ("device_observation_failed" if finalized else "task_timeout")
assert bool(item.get("timed_out")) is not finalized
next_worker = await asyncio.create_subprocess_exec(sys.executable, "-c", "pass")
assert (
await asyncio.wait_for(
TaskQueueService._await_worker_with_budget(
next_worker, {"session_id": "next"}, "next"
),
10,
)
== 0
)
finally:
if hung.returncode is None:
hung.kill()
await hung.wait()
+16
View File
@@ -47,6 +47,17 @@ else:
del sys.modules["artemis.graph.state"] del sys.modules["artemis.graph.state"]
import base64
from io import BytesIO
from PIL import Image
def _valid_screen_base64():
buffer = BytesIO()
Image.new("RGB", (64, 64), "white").save(buffer, format="PNG")
return base64.b64encode(buffer.getvalue()).decode("ascii")
@pytest.fixture @pytest.fixture
def mock_context(): def mock_context():
"""Create a mock ArtemisContext for testing.""" """Create a mock ArtemisContext for testing."""
@@ -70,6 +81,7 @@ def mock_context():
# Mock the ADB client for Android # Mock the ADB client for Android
mock_response = Mock() mock_response = Mock()
mock_response.base64 = _valid_screen_base64()
mock_response.json.return_value = {"elements": []} mock_response.json.return_value = {"elements": []}
ctx.ui_adb_client.get_screen_data = Mock(return_value=mock_response) ctx.ui_adb_client.get_screen_data = Mock(return_value=mock_response)
ctx.ui_adb_client.get_hierarchy = Mock( ctx.ui_adb_client.get_hierarchy = Mock(
@@ -288,6 +300,7 @@ class TestFocusElementIfNeeded:
focused_element["attributes"]["focused"] = "true" focused_element["attributes"]["focused"] = "true"
mock_response = Mock() mock_response = Mock()
mock_response.base64 = _valid_screen_base64()
mock_response.json.return_value = {"elements": [focused_element]} mock_response.json.return_value = {"elements": [focused_element]}
mock_context.ui_adb_client.get_screen_data = Mock(return_value=mock_response) mock_context.ui_adb_client.get_screen_data = Mock(return_value=mock_response)
mock_find_element.return_value = focused_element["attributes"] mock_find_element.return_value = focused_element["attributes"]
@@ -366,6 +379,7 @@ class TestFocusElementIfNeeded:
} }
mock_response = Mock() mock_response = Mock()
mock_response.base64 = _valid_screen_base64()
mock_response.json.return_value = {"elements": [element_from_text]} mock_response.json.return_value = {"elements": [element_from_text]}
mock_context.ui_adb_client.get_screen_data = Mock(return_value=mock_response) mock_context.ui_adb_client.get_screen_data = Mock(return_value=mock_response)
mock_find_id.return_value = element_from_id mock_find_id.return_value = element_from_id
@@ -401,6 +415,7 @@ class TestFocusElementIfNeeded:
} }
mock_response = Mock() mock_response = Mock()
mock_response.base64 = _valid_screen_base64()
mock_response.json.return_value = {"elements": [element_with_bounds]} mock_response.json.return_value = {"elements": [element_with_bounds]}
mock_context.ui_adb_client.get_screen_data = Mock(return_value=mock_response) mock_context.ui_adb_client.get_screen_data = Mock(return_value=mock_response)
mock_find_text.return_value = element_with_bounds["attributes"] mock_find_text.return_value = element_with_bounds["attributes"]
@@ -428,6 +443,7 @@ class TestFocusElementIfNeeded:
"""Test failure when no locator can find an element.""" """Test failure when no locator can find an element."""
mock_response = Mock() mock_response = Mock()
mock_response.base64 = _valid_screen_base64()
mock_response.json.return_value = {"elements": []} mock_response.json.return_value = {"elements": []}
mock_context.ui_adb_client.get_screen_data = Mock(return_value=mock_response) mock_context.ui_adb_client.get_screen_data = Mock(return_value=mock_response)
with ( with (
Generated
+1 -1
View File
@@ -206,7 +206,7 @@ wheels = [
[[package]] [[package]]
name = "artemis" name = "artemis"
version = "1.3.0" version = "1.3.1"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "adbutils" }, { name = "adbutils" },