feat: add multi-device concurrency, server lifecycle CLI, and unified execution stream

- Implement DevicePool and DeviceExecutionLock concurrency modes (per-device FIFO vs global serialization)
- Extend ArtemisClient SDK and CLI with explicit device targeting and concurrency strategy configuration
- Add server lifecycle management (artemis restart, stop, status) with cross-platform PID discovery
- Update MCP server tools to support multi-device dispatch, trace tagging, and per-device execution
- Enhance Showcase UI with step-by-step visual replay, playback speed controls, and device serial badges
- Add comprehensive unit test coverage and edge-case verification scripts
This commit is contained in:
somew1nd committed 2026-08-26 15:53:14 -07:00
1 parent a36a66ce6d
commit c2c90a6378
84 files changed
+5612 -645

No files matched your search

+18 -11
View File
@@ -208,21 +208,28 @@ Embed the mobile automation engine into your Python workflows in just a few line
```python
import asyncio
from artemis.interfaces.sdk import ArtemisClient
from artemis import ArtemisClient, ConcurrencyMode
async def main():
# Initialize client (choose "flash" for fast UI checks or "pro" for deep reasoning & self-healing)
client = ArtemisClient(default_profile="flash")
# Execute natural language end-to-end test case
result = await client.run(
"Open System Settings, go to 'Battery', verify battery percentage is displayed, and check for any crash dialogs."
# 1. Initialize client with optional device targeting and concurrency strategy:
# - concurrency_mode="per_device" (default): 1 task per device, allows multi-device parallel execution
# - concurrency_mode="global": 1 task globally across all devices
client = ArtemisClient(
device_serial="emulator-5554", # optional: target specific device serial
default_profile="flash", # "flash" (fast reactive) or "pro" (deep reasoning)
concurrency_mode="per_device", # or ConcurrencyMode.GLOBAL
)
# Structured assertions & execution tracing
assert result.status == "SUCCESS", f"Test failed: {result.failure_reason}"
print(f"✅ Test Passed! Turns: {result.turns} | Trace ID: {result.trace_id}")
# 2. Execute natural language end-to-end test case (can also override device per-run)
result = await client.run(
"Open System Settings, go to 'Battery', verify battery percentage is displayed, and check for any crash dialogs.",
device_serial="emulator-5554", # optional: override target device for this specific run
)
# 3. Structured assertions & execution tracing
assert result.status == "SUCCESS", f"Test failed: {result.error}"
print(f"✅ Test Passed! Device: {result.device_id} | Turns: {result.turns} | Trace ID: {result.trace_id}")
if __name__ == "__main__":
@@ -239,7 +246,7 @@ if __name__ == "__main__":
<sub><b>Console Overview</b>: <b>① View Switcher</b> (Home / Workspace) · <b>② Model & Replay</b> (Flash/Pro status & video replay) · <b>③ Live Agent Stream</b> (Action perception, target coordinates & structured results) · <b>④ Prompt Dock</b> (Natural language dispatch) · <b>⑤ Task Queue & Dashboard</b> (Lifecycle & history)</sub>
</p>
* **Web Visual Test Console (`uv run artemis ui`)**: Real-time screen projection and interactive panel, supporting natural language test dispatch, live reasoning telemetry, action trajectories, and execution replay;
* **Web Visual Test Console (`uv run artemis ui`)**: Real-time screen projection and interactive panel, supporting natural language test dispatch, live reasoning telemetry, action trajectories, and execution replay; manage server lifecycle anytime from any terminal using `uv run artemis restart`, `uv run artemis stop`, and `uv run artemis status`;
* **Native MCP Protocol (IDE Collaboration)**: Operates as a standard MCP server seamlessly integrating with **Antigravity, Claude Code, Windsurf**, etc., directly driving real devices inside the IDE to verify bugs and run test cases;
* **Developer CLI (`uv run artemis run`)**: Direct terminal execution for automated test cases, exploratory stability inspection, or AndroidWorld benchmarks with high-fidelity structured terminal output;
* **Python SDK**: Integrates as a standard Python library into existing automated testing frameworks (e.g., pytest) or CI/CD pipelines with strongly typed Pydantic structured outputs and assertion support.
+18 -11
View File
@@ -210,21 +210,28 @@ PYTHONPATH = "/path/to/artemis"
```python
import asyncio
from artemis.interfaces.sdk import ArtemisClient
from artemis import ArtemisClient, ConcurrencyMode
async def main():
# 初始化测试客户端(支持 "flash" 极速校验模式 或 "pro" 深度推理与自愈模式)
client = ArtemisClient(default_profile="flash")
# 执行自然语言端到端测试用例
result = await client.run(
"打开系统设置,进入『电池』页面,验证是否正常显示电量百分比,确认页面无异常报错弹窗。"
# 1. 初始化测试客户端(支持指定设备序列号与并发策略):
# - concurrency_mode="per_device"(默认):单设备排队,跨设备支持多任务并发执行
# - concurrency_mode="global":全局单一并发,整个集群跨所有设备同一时刻只执行一个任务
client = ArtemisClient(
device_serial="emulator-5554", # 可选:指定目标设备序列号(不传则自动选择空闲设备)
default_profile="flash", # "flash" 极速校验 或 "pro" 深度推理自愈
concurrency_mode="per_device", # 或 ConcurrencyMode.GLOBAL
)
# 结构化断言与执行追溯
assert result.status == "SUCCESS", f"测试执行失败: {result.failure_reason}"
print(f"✅ 测试通过!耗时步数: {result.turns} | Trace ID: {result.trace_id}")
# 2. 执行端到端任务(也可在调用时动态覆盖指定设备与并发模式)
result = await client.run(
"打开系统设置,进入『电池』页面,验证是否正常显示电量百分比,确认页面无异常报错弹窗。",
device_serial="emulator-5554", # 可选:覆盖当前调用的目标设备
)
# 3. 结构化断言与执行追溯
assert result.status == "SUCCESS", f"测试执行失败: {result.error}"
print(f"✅ 测试通过!设备: {result.device_id} | 步数: {result.turns} | Trace ID: {result.trace_id}")
if __name__ == "__main__":
@@ -241,7 +248,7 @@ if __name__ == "__main__":
<sub><b>控制台功能概览</b>:<b>① 视图切换</b>(主页与工作区) · <b>② 运行模式与回放</b>(Flash/Pro 状态与视频回放) · <b>③ 实时感知推理流</b>(动作感知、目标坐标与结构化总结) · <b>④ 提示词输入坞 (Prompt Dock)</b>(自然语言下发) · <b>⑤ 任务队列看板</b>(生命周期与历史回溯)</sub>
</p>
* **Web 可视化测试控制台 (`uv run artemis ui`)**:集成设备实时投屏与交互面板,支持通过自然语言下发测试用例,实时观测推理步骤、操作轨迹、截图留存与异常状态回放;
* **Web 可视化测试控制台 (`uv run artemis ui`)**:集成设备实时投屏与交互面板,支持通过自然语言下发测试用例,实时观测推理步骤、操作轨迹、截图留存与异常状态回放;支持在任意终端使用 `uv run artemis restart`、`uv run artemis stop`、`uv run artemis status` 一键重启、关停或查看服务状态;
* **原生 MCP 协议 (IDE 协同)**:作为标准 MCP 服务器无缝接入 **Antigravity、Claude Code、Windsurf** 等开发环境,在 IDE 中直接驱动真机完成自动化测试与 Bug 复现验证;
* **开发者命令行 CLI (`uv run artemis run`)**:支持通过终端直接执行自动化测试用例、探索性稳定性巡检或 AndroidWorld 基准评测,提供高保真结构化终端输出;
* **Python SDK**:作为标准 Python 库无缝集成至现有自动化测试框架(如 pytest)或 CI/CD 流水线,提供基于 Pydantic 的强类型结构化结果与断言支持。
+2
View File
@@ -30,6 +30,8 @@ class ServerState:
self.ipc_server: asyncio.Server | None = None
self.ipc_serve_task: asyncio.Task | None = None
self.ipc_port: int | None = None
self.port: int = 8000
self.host: str = "0.0.0.0"
self.is_shutting_down: bool = False
self.current_process: asyncio.subprocess.Process | None = None
+3 -1
View File
@@ -1148,13 +1148,15 @@
const time = new Date(session.start_time * 1000).toLocaleString();
const titleText = session.initial_goal || 'Unnamed Session';
const sidDisplay = session.session_id || '';
const deviceSerial = session.device_serial || session.device_id || '';
const devicePrefix = deviceSerial ? `${escapeHtml(deviceSerial)} · ` : '';
div.innerHTML = `
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
<div style="flex: 1; min-width: 0;">
<h3 style="font-size: 0.95rem; margin-bottom: 3px; margin-top: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${titleText}">${titleText}</h3>
<div style="font-size: 0.75rem; font-family: monospace; opacity: 0.85; margin-bottom: 3px; word-break: break-all; color: ${session.session_id === currentSessionId ? '#edf2f7' : '#4a5568'};">ID: ${sidDisplay}</div>
<small style="color: ${session.session_id === currentSessionId ? '#e2e8f0' : '#718096'}; font-size: 0.75rem;">${time}</small>
<small style="color: ${session.session_id === currentSessionId ? '#e2e8f0' : '#718096'}; font-size: 0.75rem;">${devicePrefix}${time}</small>
</div>
<button onclick="deleteSession(event, '${session.session_id}')" style="background: none; border: none; color: ${session.session_id === currentSessionId ? '#fff' : '#e53e3e'}; cursor: pointer; font-size: 1.1rem; padding: 0 0 0 8px; line-height: 1; flex-shrink: 0;" title="Delete Session">🗑️</button>
</div>
+30 -2
View File
@@ -12,6 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import json
import time
from uuid import UUID
from fastapi import APIRouter, HTTPException
@@ -49,12 +50,39 @@ async def list_sessions():
unresolved_profiles = []
result = []
try:
from artemis.runtime import DeviceExecutionLock
active_owners = DeviceExecutionLock.get_active_owners()
active_owner_sids = {
str(owner.session_id)
for owner in active_owners.values()
if owner.session_id
}
except Exception:
active_owner_sids = set()
for row_dict in rows:
s_id = str(row_dict.get("session_id"))
recording = latest_recordings.get(s_id)
d_info_raw = row_dict.get("device_info")
device_id = None
if d_info_raw:
try:
d_info = json.loads(d_info_raw) if isinstance(d_info_raw, str) else d_info_raw
if isinstance(d_info, dict):
device_id = d_info.get("device_id") or d_info.get("device_serial")
except Exception:
pass
if not device_id and recording:
device_id = recording.get("device_id")
row_dict["device_id"] = device_id
row_dict["device_serial"] = device_id
if row_dict.get("status") == "running":
is_active = s_id in state.active_connections or (
state.is_running and s_id == str(state.active_session_id)
is_active = (
s_id in active_owner_sids
or s_id in state.active_connections
or (state.is_running and s_id == str(state.active_session_id))
)
worker_is_alive = session_repo.process_is_alive(row_dict.get("pid"))
if not is_active and not worker_is_alive:
+93
View File
@@ -360,3 +360,96 @@ async def get_model_config_and_env():
"env_filename": ".env",
"env_vars": env_vars,
}
@router.get("/server-status")
async def get_server_runtime_status():
"""Retrieve runtime status, PID, port, and uptime of the Artemis server."""
import os
from artemis.runtime.server_lifecycle import get_server_status
try:
from apps.admin_console.core.state import state
except ImportError:
from admin_console.core.state import state
port = getattr(state, "port", 8000)
status = get_server_status(port=port)
status["current_pid"] = os.getpid()
return status
@router.post("/restart")
async def restart_server_endpoint():
"""Request a graceful restart of the Artemis server from thin clients/UI."""
import asyncio
import os
import sys
import threading
try:
from apps.admin_console.core.state import state
except ImportError:
from admin_console.core.state import state
port = getattr(state, "port", 8000)
current_pid = os.getpid()
def _restart_worker():
import time
time.sleep(0.6)
if sys.platform != "win32":
try:
os.execv(sys.executable, [sys.executable] + sys.argv)
except Exception:
import subprocess
subprocess.Popen([sys.executable] + sys.argv)
os._exit(0)
else:
import subprocess
subprocess.Popen([sys.executable] + sys.argv)
os._exit(0)
threading.Thread(target=_restart_worker, daemon=True).start()
return {
"status": "restarting",
"message": "Artemis server is restarting. Client reconnection should occur in 2-3 seconds.",
"previous_pid": current_pid,
"port": port,
}
@router.post("/shutdown")
async def shutdown_server_endpoint():
"""Request a graceful shutdown of the Artemis server."""
import os
import signal
import threading
try:
from apps.admin_console.core.state import state
except ImportError:
from admin_console.core.state import state
current_pid = os.getpid()
def _shutdown_worker():
import time
time.sleep(0.5)
try:
os.kill(current_pid, signal.SIGTERM)
except Exception:
os._exit(0)
threading.Thread(target=_shutdown_worker, daemon=True).start()
return {
"status": "shutting_down",
"message": "Artemis server is shutting down.",
"pid": current_pid,
}
+46 -3
View File
@@ -18,7 +18,7 @@ import json
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from artemis.core.diagnostics import readiness_engine
from artemis.runtime import DeviceExecutionLock
from artemis.runtime import DeviceExecutionLock, device_pool
try:
from admin_console.core.state import state
@@ -99,9 +99,17 @@ async def run_task(request: RunRequest):
enable_outputter=request.enable_outputter,
locked_app_package=request.locked_app_package,
app_path=request.app_path,
device_serial=request.device_serial,
)
@router.get("/api/devices")
async def list_devices():
"""List all connected Android devices with their busy / idle status."""
devices = await device_pool.list_devices_async()
return {"devices": [d.to_dict() for d in devices]}
@router.post("/api/stop")
async def stop_task(all: bool = False):
stopped = task_queue_service.stop_tasks(clear_all=all)
@@ -138,10 +146,24 @@ async def get_status():
None,
)
active_owners = DeviceExecutionLock.get_active_owners()
active_tasks = [
{
"device_id": owner.device_id,
"session_id": owner.session_id,
"goal": owner.description,
"pid": owner.pid,
"ingress": owner.ingress,
"acquired_at": owner.acquired_at,
}
for owner in active_owners.values()
]
running_sid = (
(global_owner.session_id if global_owner else None)
or state.active_session_id
or (running_task.get("session_id") if running_task else None)
or (active_tasks[0]["session_id"] if active_tasks and active_tasks[0].get("session_id") else None)
or (session_repo.get_running_session_id() if is_running else None)
)
owner_connection = (
@@ -152,6 +174,7 @@ async def get_status():
or (global_owner.description if global_owner else None)
or state.current_goal
or (running_task.get("goal") if running_task else None)
or (active_tasks[0]["goal"] if active_tasks else None)
)
active_profile = owner_connection.get("profile") or state.current_profile or (
@@ -168,7 +191,23 @@ async def get_status():
)
model_info = model_service.get_active_model_info(active_profile)
queue_data = state.queue_tasks
# Unified Global Queue: merge web tasks and external SDK/CLI device queue tickets
global_queued = DeviceExecutionLock.get_queued_tasks()
seen_ids = set()
queue_data: list[dict[str, Any]] = []
for item in state.queue_tasks:
sid = item.get("session_id")
if sid:
seen_ids.add(str(sid))
queue_data.append(item)
for g_item in global_queued:
sid = str(g_item.get("session_id"))
if sid not in seen_ids:
seen_ids.add(sid)
queue_data.append(g_item)
if is_running:
is_paused = state.is_paused
@@ -187,6 +226,7 @@ async def get_status():
"background_tasks": bg_tasks,
"queue": queue_data,
"model_info": model_info,
"active_tasks": active_tasks,
}
if latest_session_id and str(latest_session_id) in state.active_connections:
@@ -203,6 +243,8 @@ async def get_status():
"background_tasks": bg_tasks,
"queue": queue_data,
"model_info": conn_model_info,
"active_tasks": active_tasks,
"ipc_port": state.ipc_port,
}
return {
@@ -210,7 +252,8 @@ async def get_status():
"session_id": latest_session_id,
"background_tasks": bg_tasks,
"queue": queue_data,
"model_info": model_info,
"active_tasks": active_tasks,
"ipc_port": state.ipc_port,
}
@@ -23,6 +23,8 @@ class RunRequest(BaseModel):
enable_outputter: bool | None = None
locked_app_package: str | None = None
app_path: str | None = None
device_serial: str | None = None
class ReplayRequest(BaseModel):
+23 -12
View File
@@ -49,8 +49,10 @@ import uvicorn
from artemis.runtime import (
DeviceExecutionLock,
clear_server_info,
shutdown_awake_service,
start_awake_service,
write_server_info,
)
try:
@@ -108,6 +110,7 @@ async def on_startup():
"""Startup lifecycle hooks."""
state.is_shutting_down = False
state.shutdown_event.clear()
write_server_info(port=getattr(state, "port", 8000), host=getattr(state, "host", "0.0.0.0"))
await asyncio.to_thread(start_awake_service)
cleaned_device_locks = DeviceExecutionLock.cleanup_stale_locks()
if cleaned_device_locks:
@@ -160,6 +163,7 @@ async def on_shutdown():
await ipc_service.stop_server()
state.ipc_subscribers.clear()
await asyncio.to_thread(shutdown_awake_service)
clear_server_info()
# Mount modular routers
@@ -382,23 +386,30 @@ class ArtemisUvicornServer(uvicorn.Server):
def run_ui_server(host: str, port: int, reload: bool = False) -> None:
"""Run the UI server with bounded, signal-aware graceful shutdown."""
if reload:
uvicorn.run(
state.host = host
state.port = port
write_server_info(port=port, host=host)
try:
if reload:
uvicorn.run(
"apps.admin_console.server:app",
host=host,
port=port,
reload=True,
timeout_graceful_shutdown=5,
)
return
config = uvicorn.Config(
app,
host=host,
port=port,
reload=True,
timeout_graceful_shutdown=5,
)
return
config = uvicorn.Config(
app,
host=host,
port=port,
timeout_graceful_shutdown=5,
)
ArtemisUvicornServer(config).run()
ArtemisUvicornServer(config).run()
finally:
clear_server_info()
def main():
@@ -334,12 +334,12 @@ PRESET_TASK_CATALOG: list[TaskPreset] = [
required_packages=["com.google.android.apps.photos"],
priority=82,
),
# 9. WeChat (微信)
# 9. WeChat
TaskPreset(
id="wechat_browse",
title="Check WeChat Messages",
description="Open WeChat and view top recent chat conversations",
goal="Open WeChat (微信) and view the top recent chat messages.",
goal="Open WeChat and view the top recent chat messages.",
profile="flash",
category="flash",
tag="WeChat",
@@ -351,7 +351,7 @@ PRESET_TASK_CATALOG: list[TaskPreset] = [
id="pro_wechat_to_calendar",
title="WeChat Notice to Calendar",
description="Extract meeting notice from WeChat chat and add to Calendar",
goal="Open WeChat (微信), locate the latest meeting announcement or event message in the top chat, extract the time and topic, then open Calendar and schedule an event.",
goal="Open WeChat, locate the latest meeting announcement or event message in the top chat, extract the time and topic, then open Calendar and schedule an event.",
profile="pro",
category="cross_app",
tag="WeChat + Calendar",
@@ -367,12 +367,12 @@ PRESET_TASK_CATALOG: list[TaskPreset] = [
required_packages=["com.tencent.mm"],
priority=93,
),
# 10. Xiaohongshu (小红书)
# 10. Xiaohongshu
TaskPreset(
id="xhs_coffee_guide",
title="RED Cafe Guide Search",
description="Search trending specialty cafe reviews on Xiaohongshu",
goal="Open Xiaohongshu (小红书), search for top-rated specialty coffee shops, and view the top post.",
goal="Open Xiaohongshu, search for top-rated specialty coffee shops, and view the top post.",
profile="flash",
category="flash",
tag="Xiaohongshu",
@@ -384,12 +384,12 @@ PRESET_TASK_CATALOG: list[TaskPreset] = [
required_packages=["com.xingin.xhs"],
priority=87,
),
# 11. Meituan / Dianping (美团 / 大众点评)
# 11. Meituan / Dianping
TaskPreset(
id="meituan_ramen_search",
title="Meituan Food Search",
description="Search top-rated Ramen nearby on Meituan or Dianping",
goal="Open Meituan (美团) or Dianping (大众点评), search for top-rated Ramen nearby, and view top restaurant rating.",
goal="Open Meituan or Dianping, search for top-rated Ramen nearby, and view top restaurant rating.",
profile="flash",
category="flash",
tag="Meituan",
@@ -401,12 +401,12 @@ PRESET_TASK_CATALOG: list[TaskPreset] = [
required_packages=["com.sankuai.meituan", "com.dianping.v1"],
priority=86,
),
# 12. Bilibili (哔哩哔哩)
# 12. Bilibili
TaskPreset(
id="bilibili_stream",
title="Bilibili Tech Video",
description="Search and play an AI Agent tutorial video on Bilibili",
goal='Open Bilibili (哔哩哔哩), search for "AI Agent 架构实战", and play the top matching video.',
goal='Open Bilibili, search for "AI Agent Architecture", and play the top matching video.',
profile="flash",
category="flash",
tag="Bilibili",
@@ -241,6 +241,11 @@ class TaskQueueService:
async def queue_worker(cls):
"""Persistent, self-healing background worker loop processing tasks in FIFO order."""
print("[QueueWorker] Background worker loop initialized and running.")
try:
DeviceExecutionLock.cleanup_stale_locks()
except Exception as exc:
print(f"[QueueWorker] Initial stale lock cleanup notice: {exc}")
while True:
task_item = None
sess_id = None
@@ -289,6 +294,7 @@ class TaskQueueService:
"session_id": sess_id,
"initial_goal": goal,
"profile": profile,
"device_serial": task_item.get("device_serial"),
},
)
@@ -322,6 +328,8 @@ class TaskQueueService:
"--test-name",
test_name,
]
if sess_id:
cmd.extend(["--session-id", str(sess_id)])
if expected_output:
cmd.extend(["--output-description", str(expected_output)])
if enable_outputter is not None:
@@ -330,9 +338,13 @@ class TaskQueueService:
cmd.extend(["--locked-app", str(locked_app)])
if app_path:
cmd.extend(["--app-path", str(app_path)])
device_serial = task_item.get("device_serial")
if device_serial:
cmd.extend(["--device-serial", str(device_serial)])
env["ADB_DEVICE_SERIAL"] = str(device_serial)
print(
f"[QueueWorker] Starting task [{sess_id}]: '{goal}' (profile: {profile}, outputter: {bool(expected_output or enable_outputter)})"
f"[QueueWorker] Starting task [{sess_id}]: '{goal}' (profile: {profile}, device: {device_serial or 'auto'}, outputter: {bool(expected_output or enable_outputter)})"
)
proc = await asyncio.create_subprocess_exec(
*cmd,
@@ -349,6 +361,7 @@ class TaskQueueService:
str(queue_ticket),
proc.pid,
description=f"frontend task: {goal[:120]}",
device_id=device_serial or "pending",
session_id=str(sess_id) if sess_id else None,
ingress="frontend",
)
@@ -453,6 +466,7 @@ class TaskQueueService:
enable_outputter: bool | None = None,
locked_app_package: str | None = None,
app_path: str | None = None,
device_serial: str | None = None,
) -> dict[str, Any]:
"""Enqueues one or more goals and wakes up the background worker."""
cls.ensure_worker_running()
@@ -461,8 +475,18 @@ class TaskQueueService:
now = time.time()
for i, goal in enumerate(goals):
sess_id = str(uuid.uuid4())
assigned_serial = device_serial
if not assigned_serial:
try:
from artemis.runtime import device_pool
assigned_serial = device_pool.select_device()
except Exception:
assigned_serial = None
queue_ticket = DeviceExecutionLock.reserve(
description=f"frontend task: {goal[:120]}",
device_id=assigned_serial or "pending",
session_id=sess_id,
ingress="frontend",
)
@@ -474,6 +498,7 @@ class TaskQueueService:
"enable_outputter": enable_outputter,
"locked_app_package": locked_app_package,
"app_path": app_path,
"device_serial": assigned_serial,
"status": "pending",
"queue_ticket": queue_ticket,
"created_at": now + i * 0.001,
@@ -68,6 +68,11 @@
{{ getTaskStatus(session) | uppercase }}
</span>
<div class="item-header-actions">
<span class="item-device" *ngIf="getDeviceSerial(session)" [title]="'Device: ' + getDeviceSerial(session)">
<span class="material-symbols-outlined device-icon">phone_android</span>
<span class="device-name">{{ getDeviceSerial(session) }}</span>
</span>
<span class="item-time-divider" *ngIf="getDeviceSerial(session)">·</span>
<span class="item-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
<button
*ngIf="getTaskStatus(session) !== 'running' && getTaskStatus(session) !== 'paused'"
@@ -113,6 +118,11 @@
{{ getTaskStatus(session) | uppercase }}
</span>
<div class="item-header-actions">
<span class="item-device" *ngIf="getDeviceSerial(session)" [title]="'Device: ' + getDeviceSerial(session)">
<span class="material-symbols-outlined device-icon">phone_android</span>
<span class="device-name">{{ getDeviceSerial(session) }}</span>
</span>
<span class="item-time-divider" *ngIf="getDeviceSerial(session)">·</span>
<span class="item-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
<button
*ngIf="getTaskStatus(session) !== 'running' && getTaskStatus(session) !== 'paused'"
@@ -550,7 +550,8 @@ $stream-row-height: 25px;
transform: translateY(-1px);
.item-header .item-header-actions {
&:has(.btn-delete-card) .item-time {
&:has(.btn-delete-card) .item-time,
&:has(.btn-delete-card) .item-time-divider {
opacity: 0;
}
@@ -580,8 +581,11 @@ $stream-row-height: 25px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
.task-badge {
flex-shrink: 0;
display: inline-flex;
align-items: center;
font-size: 10.5px;
@@ -658,11 +662,67 @@ $stream-row-height: 25px;
.item-header-actions {
display: flex;
align-items: center;
gap: 4px;
position: relative;
min-width: 0;
flex-shrink: 1;
justify-content: flex-end;
.item-device {
display: inline-flex;
align-items: center;
gap: 2.5px;
min-width: 0;
flex-shrink: 1;
font-size: 10.5px;
color: #64748b;
line-height: 1;
cursor: default;
transition: color 0.15s ease, opacity 0.15s ease;
.device-icon {
font-size: 12px;
width: 12px;
height: 12px;
line-height: 12px;
color: #64748b;
opacity: 0.75;
flex-shrink: 0;
}
.device-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
font-family: inherit;
letter-spacing: -0.15px;
}
&:hover {
color: #334155;
.device-icon {
color: #334155;
opacity: 1;
}
}
}
.item-time-divider {
font-size: 10px;
color: #94a3b8;
line-height: 1;
user-select: none;
flex-shrink: 0;
transition: opacity 0.15s ease;
}
.item-time {
font-size: 11px;
color: #64748b;
white-space: nowrap;
flex-shrink: 0;
line-height: 1;
transition: opacity 0.15s ease;
}
@@ -253,9 +253,11 @@ export class AgentStreamComponent implements AfterViewInit {
return list.sort((a, b) => {
const statusA = this.getTaskStatus(a);
const statusB = this.getTaskStatus(b);
if (statusA === 'running' || statusA === 'paused') return -1;
if (statusB === 'running' || statusB === 'paused') return 1;
return a.start_time - b.start_time;
const isRunA = statusA === 'running' || statusA === 'paused';
const isRunB = statusB === 'running' || statusB === 'paused';
if (isRunA && !isRunB) return -1;
if (!isRunA && isRunB) return 1;
return (a.start_time || 0) - (b.start_time || 0);
});
});
@@ -598,18 +600,43 @@ export class AgentStreamComponent implements AfterViewInit {
}
public getTaskStatus(session: Session): 'running' | 'paused' | 'completed' | 'pending' | 'failed' | 'cancelled' {
if (session.session_id === this.agentService.runningSessionId() && (this.agentService.agentStatus() === 'running' || this.agentService.agentStatus() === 'paused')) {
return this.agentService.agentStatus() as 'running' | 'paused';
}
if (session.status) {
const s = session.status.toLowerCase();
if (s === 'running' || s === 'paused' || s === 'completed' || s === 'pending' || s === 'failed' || s === 'cancelled') {
if (s === 'completed' || s === 'success' || s === 'failed' || s === 'cancelled') {
return (s === 'success' ? 'completed' : s) as any;
}
if (s === 'running' || s === 'paused' || s === 'pending') {
return s as any;
}
}
if (session.session_id === this.agentService.runningSessionId() && (this.agentService.agentStatus() === 'running' || this.agentService.agentStatus() === 'paused')) {
return this.agentService.agentStatus() as 'running' | 'paused';
}
return 'completed';
}
/**
* Determine the device serial number for the session
*/
public getDeviceSerial(session: Session): string | null {
const serial = session.device_serial || session.device_id;
if (serial && serial !== 'pending' && serial !== 'null' && serial !== 'undefined') {
return serial;
}
if (session.device_info) {
try {
const info = typeof session.device_info === 'string' ? JSON.parse(session.device_info) : session.device_info;
const s = info?.device_id || info?.device_serial;
if (s && s !== 'pending' && s !== 'null' && s !== 'undefined') {
return s;
}
} catch {
// ignore
}
}
return null;
}
public selectTask(sessionId: string, event?: Event): void {
if (event) event.stopPropagation();
this.agentService.selectSession(sessionId, true);
@@ -77,6 +77,11 @@
{{ getTaskStatus(session) | uppercase }}
</span>
<div class="task-card-header-actions">
<span class="task-device" *ngIf="getDeviceSerial(session)" [title]="'Device: ' + getDeviceSerial(session)">
<span class="material-symbols-outlined device-icon">phone_android</span>
<span class="device-name">{{ getDeviceSerial(session) }}</span>
</span>
<span class="task-time-divider" *ngIf="getDeviceSerial(session)">·</span>
<span class="task-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
<button
*ngIf="getTaskStatus(session) !== 'running' && getTaskStatus(session) !== 'paused'"
@@ -126,6 +131,11 @@
{{ getTaskStatus(session) | uppercase }}
</span>
<div class="task-card-header-actions">
<span class="task-device" *ngIf="getDeviceSerial(session)" [title]="'Device: ' + getDeviceSerial(session)">
<span class="material-symbols-outlined device-icon">phone_android</span>
<span class="device-name">{{ getDeviceSerial(session) }}</span>
</span>
<span class="task-time-divider" *ngIf="getDeviceSerial(session)">·</span>
<span class="task-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
<button
*ngIf="getTaskStatus(session) !== 'running' && getTaskStatus(session) !== 'paused'"
@@ -296,7 +296,8 @@ $text-muted: #a1a1aa;
box-shadow: 0 3px 8px rgba(148, 163, 184, 0.08);
.task-card-header .task-card-header-actions {
&:has(.btn-delete-card) .task-time {
&:has(.btn-delete-card) .task-time,
&:has(.btn-delete-card) .task-time-divider {
opacity: 0;
}
@@ -327,8 +328,11 @@ $text-muted: #a1a1aa;
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
min-width: 0;
.task-badge {
flex-shrink: 0;
display: inline-flex;
align-items: center;
font-size: 10.5px;
@@ -405,11 +409,67 @@ $text-muted: #a1a1aa;
.task-card-header-actions {
display: flex;
align-items: center;
gap: 4px;
position: relative;
min-width: 0;
flex-shrink: 1;
justify-content: flex-end;
.task-device {
display: inline-flex;
align-items: center;
gap: 2.5px;
min-width: 0;
flex-shrink: 1;
font-size: 11px;
color: #94a3b8;
line-height: 1;
cursor: default;
transition: color 0.15s ease, opacity 0.15s ease;
.device-icon {
font-size: 12.5px;
width: 12.5px;
height: 12.5px;
line-height: 12.5px;
color: #94a3b8;
opacity: 0.75;
flex-shrink: 0;
}
.device-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
font-family: inherit;
letter-spacing: -0.15px;
}
&:hover {
color: #64748b;
.device-icon {
color: #64748b;
opacity: 1;
}
}
}
.task-time-divider {
font-size: 10px;
color: #cbd5e1;
line-height: 1;
user-select: none;
flex-shrink: 0;
transition: opacity 0.15s ease;
}
.task-time {
font-size: 11.5px;
color: #94a3b8;
white-space: nowrap;
flex-shrink: 0;
line-height: 1;
transition: opacity 0.15s ease;
}
@@ -49,9 +49,11 @@ export class ChatInterfaceComponent {
return list.sort((a, b) => {
const statusA = this.getTaskStatus(a);
const statusB = this.getTaskStatus(b);
if (statusA === 'running' || statusA === 'paused') return -1;
if (statusB === 'running' || statusB === 'paused') return 1;
return a.start_time - b.start_time; // FIFO order
const isRunA = statusA === 'running' || statusA === 'paused';
const isRunB = statusB === 'running' || statusB === 'paused';
if (isRunA && !isRunB) return -1;
if (!isRunA && isRunB) return 1;
return (a.start_time || 0) - (b.start_time || 0); // stable FIFO order
});
});
@@ -155,18 +157,43 @@ export class ChatInterfaceComponent {
* Determine the current task execution status
*/
public getTaskStatus(session: Session): 'running' | 'paused' | 'completed' | 'pending' | 'failed' | 'cancelled' {
if (session.session_id === this.agentService.runningSessionId() && (this.agentService.agentStatus() === 'running' || this.agentService.agentStatus() === 'paused')) {
return this.agentService.agentStatus() as 'running' | 'paused';
}
if (session.status) {
const s = session.status.toLowerCase();
if (s === 'running' || s === 'paused' || s === 'completed' || s === 'pending' || s === 'failed' || s === 'cancelled') {
if (s === 'completed' || s === 'success' || s === 'failed' || s === 'cancelled') {
return (s === 'success' ? 'completed' : s) as any;
}
if (s === 'running' || s === 'paused' || s === 'pending') {
return s as any;
}
}
if (session.session_id === this.agentService.runningSessionId() && (this.agentService.agentStatus() === 'running' || this.agentService.agentStatus() === 'paused')) {
return this.agentService.agentStatus() as 'running' | 'paused';
}
return 'completed';
}
/**
* Determine the device serial number for the session
*/
public getDeviceSerial(session: Session): string | null {
const serial = session.device_serial || session.device_id;
if (serial && serial !== 'pending' && serial !== 'null' && serial !== 'undefined') {
return serial;
}
if (session.device_info) {
try {
const info = typeof session.device_info === 'string' ? JSON.parse(session.device_info) : session.device_info;
const s = info?.device_id || info?.device_serial;
if (s && s !== 'pending' && s !== 'null' && s !== 'undefined') {
return s;
}
} catch {
// ignore
}
}
return null;
}
/**
* Select a session in the UI to monitor its steps
*/
@@ -15,13 +15,13 @@
-->
<!-- Backdrop overlay only when in theater / modal mode -->
<div
class="theater-backdrop"
<div
class="theater-backdrop"
*ngIf="agentService.isVideoWindowOpen() && isTheaterMode()"
(click)="isTheaterMode.set(false)"
></div>
<div
<div
class="floating-video-wrapper"
*ngIf="agentService.isVideoWindowOpen()"
[class.minimized]="agentService.isVideoMinimized()"
@@ -43,18 +43,18 @@
<div class="header-actions" (mousedown)="$event.stopPropagation()">
<!-- Mode Toggle Group: Video vs. Step Replay -->
<div class="mode-toggle-group" *ngIf="agentService.recordingPlaybackStatus() === 'ready' && agentService.hasCurrentSessionStepFrames() && !agentService.isVideoMinimized()">
<button
class="mode-btn"
[class.active]="agentService.playerMode() === 'video'"
<button
class="mode-btn"
[class.active]="agentService.playerMode() === 'video'"
(click)="agentService.setPlayerMode('video')"
title="Video Recording"
>
<span class="material-symbols-outlined mode-icon">videocam</span>
<span>Video</span>
</button>
<button
class="mode-btn"
[class.active]="agentService.playerMode() === 'steps'"
<button
class="mode-btn"
[class.active]="agentService.playerMode() === 'steps'"
(click)="agentService.setPlayerMode('steps')"
title="Step Screenshot Replay"
>
@@ -63,22 +63,9 @@
</button>
</div>
<!-- Playback Speed Pills (Shown when video is active OR when step replay is active) -->
<div class="speed-pill-group" *ngIf="!agentService.isVideoMinimized() && (agentService.recordingPlaybackStatus() === 'ready' || agentService.playerMode() === 'steps') && (agentService.activeVideoUrl() || totalStepFrames() > 0)">
<button
*ngFor="let speed of speedOptions"
class="speed-btn"
[class.active]="playbackRate() === speed"
(click)="setSpeed(speed)"
[title]="'Speed: ' + speed + 'x'"
>
{{ speed }}x
</button>
</div>
<!-- Minimize / Restore Toggle -->
<button
class="header-btn"
<button
class="header-btn"
(click)="toggleMinimize()"
[title]="agentService.isVideoMinimized() ? 'Restore' : 'Minimize'"
aria-label="Minimize"
@@ -87,8 +74,8 @@
</button>
<!-- Theater / Fullscreen Toggle -->
<button
class="header-btn"
<button
class="header-btn"
(click)="toggleTheater()"
[title]="isTheaterMode() ? 'Exit Theater' : 'Theater Mode'"
*ngIf="!agentService.isVideoMinimized()"
@@ -98,9 +85,9 @@
</button>
<!-- Close Window -->
<button
class="header-btn close-btn"
(click)="agentService.closeVideoPlayer()"
<button
class="header-btn close-btn"
(click)="agentService.closeVideoPlayer()"
title="Close"
aria-label="Close"
>
@@ -114,11 +101,11 @@
<!-- 1. LIVE SCREEN STREAM (AUTOMATICALLY ACTIVE DURING RUNNING TEST) -->
<ng-container *ngIf="agentService.recordingPlaybackStatus() === 'live'">
<div class="live-stream-container" *ngIf="!liveStreamError()">
<img
class="live-screen-img"
[src]="liveStreamUrl()"
(error)="onLiveStreamError()"
alt="Live Device Screen"
<img
class="live-screen-img"
[src]="liveStreamUrl()"
(error)="onLiveStreamError()"
alt="Live Device Screen"
/>
<div class="live-status-overlay">
<div class="live-badge">
@@ -157,39 +144,125 @@
<!-- Fallback info banner if recording had failed -->
<div class="step-replay-notice" *ngIf="agentService.recordingPlaybackStatus() === 'failed' || videoLoadError()">
<span class="material-symbols-outlined notice-icon">info</span>
<span>Screen recording unavailable. Replaying step screenshots.</span>
<span>Screen recording unavailable. Replaying step-by-step screenshots.</span>
</div>
<div class="step-image-viewport">
<img
class="step-frame-img"
[src]="currentStepFrame()?.imageUrl"
[alt]="currentStepFrame()?.title"
(click)="openImageInNewTab()"
title="Click to view image in new tab"
/>
<!-- Step Image Viewport with Action Coordinates Overlay -->
<div
class="step-image-viewport"
(mouseenter)="onViewportMouseEnter()"
(mouseleave)="onViewportMouseLeave()"
>
<!-- Loading Skeleton (Clean Light Spinner) -->
<div class="step-image-loading-state" *ngIf="isStepImageLoading()">
<span class="material-symbols-outlined spinning-icon">progress_activity</span>
<span>Loading screenshot...</span>
</div>
<!-- Step Overlay Badge -->
<div class="step-frame-badge" *ngIf="currentStepFrame() as frame">
<span class="badge-step-no">Step {{ frame.stepNumber }}</span>
<span class="badge-action-desc">{{ frame.actionText || frame.title }}</span>
<!-- Error / Missing Image Fallback -->
<div class="step-image-error-state" *ngIf="stepImageError() && !isStepImageLoading()">
<span class="material-symbols-outlined broken-icon">broken_image</span>
<span>Screenshot preview unavailable for this step</span>
</div>
<!-- Device Screen Frame & Action Coordinates Overlay -->
<div class="step-image-frame-wrap" *ngIf="!stepImageError()">
<img
#stepImgRef
class="step-frame-img"
[src]="currentStepImageUrl()"
[alt]="currentStepFrame()?.title"
(load)="onStepImageLoad()"
(error)="onStepImageError()"
(click)="openImageInNewTab()"
title="Click to view image in new tab"
/>
<!-- Action Visual Coordinates Overlay (Touch point, swipe arrow, sequence path) -->
<div
#stepOverlayRef
class="overlay-layer"
[style.display]="viewMode() === 'post' ? 'none' : 'block'"
></div>
</div>
<!-- Hideable Floating Detail Card (Appears on hover or when pinned) -->
<div
class="step-frame-badge"
*ngIf="currentStepFrame() as frame"
[class.visible]="isCardVisible()"
(click)="$event.stopPropagation()"
>
<div class="badge-content-left">
<span class="badge-step-no">Step {{ frame.stepNumber }}</span>
<span class="badge-action-desc" [title]="frame.actionText || frame.title">
<span class="material-symbols-outlined action-type-icon" *ngIf="frame.action">
{{ getStepActionIcon(frame.action) }}
</span>
{{ frame.actionText || frame.title }}
</span>
</div>
<div class="badge-actions-right">
<!-- Pre/Post Screenshot Switcher (Only if post-screenshot is available) -->
<div class="pre-post-toggle" *ngIf="frame.postImageUrl">
<button
class="toggle-btn"
[class.active]="viewMode() === 'pre'"
(click)="setStepViewMode('pre', $event)"
title="View screen before action (with touch coordinates)"
>
Pre
</button>
<button
class="toggle-btn"
[class.active]="viewMode() === 'post'"
(click)="setStepViewMode('post', $event)"
title="View resulting screen after action executed"
>
Post
</button>
</div>
<!-- Pin Card Button -->
<button
class="pin-card-btn"
[class.pinned]="isCardPinned()"
(click)="toggleCardPin($event)"
[title]="isCardPinned() ? 'Unpin details card (auto-hide on mouse leave)' : 'Pin details card (keep always visible)'"
>
<span class="material-symbols-outlined">{{ isCardPinned() ? 'push_pin' : 'keep' }}</span>
</button>
</div>
</div>
</div>
<!-- Step Replay Controls Footer -->
<div class="video-controls-footer">
<!-- Timeline Scrubber Bar -->
<!-- Timeline Scrubber Bar with Step Ticks -->
<div class="scrubber-bar-container">
<input
type="range"
class="scrubber-range"
[min]="0"
[max]="totalStepFrames() - 1"
[step]="1"
[value]="activeStepIndex()"
(input)="onStepScrub($event)"
<input
type="range"
class="scrubber-range"
[min]="0"
[max]="totalStepFrames() - 1"
[step]="1"
[value]="activeStepIndex()"
(input)="onStepScrub($event)"
/>
<div class="scrubber-progress-fill" [style.width.%]="stepProgressPercent()"></div>
<!-- Step Tick Markers -->
<div class="step-ticks-wrapper" *ngIf="totalStepFrames() > 1">
<span
*ngFor="let f of stepFrames(); let i = index"
class="step-tick"
[class.active]="i === activeStepIndex()"
[class.passed]="i < activeStepIndex()"
[style.left.%]="(i / (totalStepFrames() - 1)) * 100"
(click)="seekStepFrame(i); $event.stopPropagation()"
[title]="'Step ' + (i + 1) + ': ' + (f.actionText || f.title)"
></span>
</div>
</div>
<!-- Bottom Controls Row -->
@@ -201,12 +274,12 @@
</button>
<!-- Previous Frame Button -->
<button class="ctrl-btn" (click)="prevStepFrame()" [disabled]="activeStepIndex() <= 0" title="Previous Frame">
<button class="ctrl-btn" (click)="prevStepFrame()" [disabled]="activeStepIndex() <= 0" title="Previous Step">
<span class="material-symbols-outlined">skip_previous</span>
</button>
<!-- Next Frame Button -->
<button class="ctrl-btn" (click)="nextStepFrame()" [disabled]="activeStepIndex() >= totalStepFrames() - 1" title="Next Frame">
<button class="ctrl-btn" (click)="nextStepFrame()" [disabled]="activeStepIndex() >= totalStepFrames() - 1" title="Next Step">
<span class="material-symbols-outlined">skip_next</span>
</button>
@@ -215,18 +288,27 @@
<span class="material-symbols-outlined">replay</span>
</button>
<!-- Frame Counter Display -->
<!-- Step Counter Display -->
<span class="time-display">
Frame {{ activeStepIndex() + 1 }} / {{ totalStepFrames() }}
Step {{ activeStepIndex() + 1 }} / {{ totalStepFrames() }}
</span>
</div>
<div class="controls-right">
<!-- Speed Button (Cycle 0.5x, 1x, 1.5x, 2x, 4x) -->
<button
class="ctrl-btn speed-pill-btn"
(click)="cycleSpeed()"
[title]="'Speed: ' + playbackRate() + 'x (Click to cycle)'"
>
<span class="speed-label">{{ playbackRate() }}x</span>
</button>
<!-- Loop Toggle -->
<button
class="ctrl-btn"
[class.active]="isLooping()"
(click)="toggleLoop()"
<button
class="ctrl-btn"
[class.active]="isLooping()"
(click)="toggleLoop()"
[title]="isLooping() ? 'Loop enabled' : 'Enable loop'"
>
<span class="material-symbols-outlined">repeat</span>
@@ -245,7 +327,7 @@
<!-- Option B: ACTIVE VIDEO PLAYBACK VIEW -->
<ng-container *ngIf="agentService.playerMode() === 'video' && agentService.recordingPlaybackStatus() === 'ready' && !agentService.isVideoLoading() && agentService.activeVideoUrl() as videoUrl">
<div class="video-container" *ngIf="!videoLoadError()">
<video
<video
#videoRef
[src]="currentVideoUrl()"
playsinline
@@ -259,18 +341,18 @@
></video>
</div>
<!-- Dedicated Bottom Controls Bar -->
<!-- Dedicated Bottom Controls Bar for Video -->
<div class="video-controls-footer" *ngIf="!videoLoadError()">
<!-- Timeline Scrubber Bar -->
<div class="scrubber-bar-container">
<input
type="range"
class="scrubber-range"
[min]="0"
[max]="duration() || 100"
[step]="0.1"
[value]="currentTime()"
(input)="onScrub($event)"
<input
type="range"
class="scrubber-range"
[min]="0"
[max]="duration() || 100"
[step]="0.1"
[value]="currentTime()"
(input)="onScrub($event)"
/>
<div class="scrubber-progress-fill" [style.width.%]="progressPercent()"></div>
</div>
@@ -283,7 +365,7 @@
<span class="material-symbols-outlined">{{ isPlaying() ? 'pause' : 'play_arrow' }}</span>
</button>
<!-- Step Replay / Reset -->
<!-- Replay / Reset -->
<button class="ctrl-btn" (click)="restart()" title="Replay from start">
<span class="material-symbols-outlined">replay</span>
</button>
@@ -295,11 +377,20 @@
</div>
<div class="controls-right">
<!-- Speed Button (Cycle 0.5x, 1x, 1.5x, 2x, 4x) -->
<button
class="ctrl-btn speed-pill-btn"
(click)="cycleSpeed()"
[title]="'Speed: ' + playbackRate() + 'x (Click to cycle)'"
>
<span class="speed-label">{{ playbackRate() }}x</span>
</button>
<!-- Loop Toggle -->
<button
class="ctrl-btn"
[class.active]="isLooping()"
(click)="toggleLoop()"
<button
class="ctrl-btn"
[class.active]="isLooping()"
(click)="toggleLoop()"
[title]="isLooping() ? 'Loop enabled' : 'Enable loop'"
>
<span class="material-symbols-outlined">repeat</span>
@@ -29,7 +29,7 @@
.floating-video-wrapper {
position: fixed;
width: 360px;
width: 400px;
max-width: calc(100vw - 32px);
background: rgba(255, 255, 255, 0.84);
backdrop-filter: blur(24px) saturate(180%);
@@ -413,7 +413,7 @@
.step-replay-container {
display: flex;
flex-direction: column;
background: #000000;
background: #ffffff;
position: relative;
width: 100%;
overflow: hidden;
@@ -422,12 +422,12 @@
display: flex;
align-items: center;
gap: 6px;
background: rgba(254, 243, 199, 0.95);
color: #92400e;
background: #fffbeb;
color: #b45309;
padding: 6px 12px;
font-size: 11.5px;
font-weight: 500;
border-bottom: 1px solid rgba(245, 158, 11, 0.3);
border-bottom: 1px solid #fde68a;
.notice-icon {
font-size: 14px;
@@ -441,62 +441,214 @@
align-items: center;
justify-content: center;
position: relative;
background: #0a0a0c;
min-height: 240px;
background: #f8fafc;
min-height: 280px;
max-height: 520px;
cursor: pointer;
overflow: hidden;
padding: 10px 0;
.step-frame-img {
max-width: 100%;
max-height: 520px;
width: auto;
height: auto;
object-fit: contain;
display: block;
user-select: none;
transition: opacity 0.15s ease;
.step-image-loading-state,
.step-image-error-state {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
background: #f8fafc;
color: #64748b;
font-size: 12px;
font-weight: 500;
z-index: 5;
&:hover {
opacity: 0.96;
.spinning-icon {
font-size: 24px;
color: #0284c7;
animation: spin 1s linear infinite;
}
.broken-icon {
font-size: 28px;
color: #94a3b8;
}
}
.step-image-frame-wrap {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
max-width: 92%;
max-height: 500px;
.step-frame-img {
max-width: 100%;
max-height: 500px;
width: auto;
height: auto;
object-fit: contain;
display: block;
user-select: none;
cursor: pointer;
border-radius: 8px;
box-shadow: 0 4px 20px -2px rgba(15, 23, 42, 0.14), 0 0 0 1px rgba(226, 232, 240, 0.9);
background: #ffffff;
transition: opacity 0.15s ease;
&:hover {
opacity: 0.98;
}
}
.overlay-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 8;
}
}
&:hover .step-frame-badge {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.step-frame-badge {
position: absolute;
bottom: 12px;
left: 12px;
right: 12px;
left: 10px;
right: 10px;
display: flex;
align-items: center;
gap: 6px;
background: rgba(15, 23, 42, 0.84);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 8px;
justify-content: space-between;
gap: 8px;
background: rgba(15, 23, 42, 0.88);
backdrop-filter: blur(14px) saturate(180%);
-webkit-backdrop-filter: blur(14px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 10px;
padding: 6px 10px;
color: #ffffff;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.45);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
opacity: 0;
transform: translateY(8px);
pointer-events: none;
animation: fadeIn 0.2s ease;
transition: opacity 0.22s cubic-bezier(0.16, 1, 0.3, 1), transform 0.22s cubic-bezier(0.16, 1, 0.3, 1);
z-index: 15;
.badge-step-no {
font-size: 11px;
font-weight: 700;
background: #0284c7;
padding: 1px 6px;
border-radius: 4px;
letter-spacing: 0.2px;
flex-shrink: 0;
&.visible {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.badge-action-desc {
font-size: 11.5px;
font-weight: 500;
color: #f8fafc;
white-space: nowrap;
.badge-content-left {
display: flex;
align-items: center;
gap: 7px;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
.badge-step-no {
font-size: 10.5px;
font-weight: 700;
background: #0284c7;
padding: 2px 7px;
border-radius: 5px;
letter-spacing: 0.3px;
flex-shrink: 0;
box-shadow: 0 1px 3px rgba(2, 132, 199, 0.4);
}
.badge-action-desc {
font-size: 11.5px;
font-weight: 500;
color: #f8fafc;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
align-items: center;
gap: 4px;
.action-type-icon {
font-size: 15px;
color: #38bdf8;
flex-shrink: 0;
}
}
}
.badge-actions-right {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
.pre-post-toggle {
display: inline-flex;
align-items: center;
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
padding: 2px;
gap: 2px;
.toggle-btn {
border: none;
background: transparent;
color: rgba(255, 255, 255, 0.7);
font-size: 10.5px;
font-weight: 700;
padding: 2px 7px;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
color: #ffffff;
background: rgba(255, 255, 255, 0.2);
}
&.active {
background: #0284c7;
color: #ffffff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
}
}
.pin-card-btn {
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.6);
cursor: pointer;
padding: 3px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: all 0.15s ease;
&:hover {
color: #ffffff;
background: rgba(255, 255, 255, 0.15);
}
&.pinned {
color: #38bdf8;
background: rgba(56, 189, 248, 0.2);
}
.material-symbols-outlined {
font-size: 14px;
}
}
}
}
}
@@ -571,6 +723,48 @@
pointer-events: none;
box-shadow: 0 0 8px rgba(2, 132, 199, 0.4);
}
.step-ticks-wrapper {
position: absolute;
left: 0;
right: 0;
height: 100%;
pointer-events: none;
display: flex;
align-items: center;
z-index: 6;
.step-tick {
position: absolute;
width: 7px;
height: 7px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.9);
border: 1.5px solid #94a3b8;
transform: translate(-50%, 0);
pointer-events: auto;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
transform: translate(-50%, 0) scale(1.4);
border-color: #0284c7;
background: #ffffff;
}
&.passed {
background: #0284c7;
border-color: #ffffff;
}
&.active {
background: #38bdf8;
border-color: #ffffff;
transform: translate(-50%, 0) scale(1.3);
box-shadow: 0 0 6px rgba(56, 189, 248, 0.8);
}
}
}
}
.controls-row {
@@ -646,6 +840,22 @@
}
}
&.speed-pill-btn {
width: auto;
min-width: 34px;
padding: 0 7px;
font-size: 11px;
font-weight: 700;
font-family: 'JetBrains Mono', 'Roboto Mono', monospace;
color: #334155;
letter-spacing: -0.2px;
&:hover {
color: #0284c7;
border-color: rgba(191, 219, 254, 0.9);
}
}
.material-symbols-outlined {
font-size: 18px;
color: #1e293b;
@@ -758,3 +968,20 @@
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
::ng-deep {
.action-point-marker {
z-index: 10;
pointer-events: none;
}
@keyframes pulse-animation {
0% {
transform: scale(0.6);
opacity: 1;
}
100% {
transform: scale(2.2);
opacity: 0;
}
}
}
@@ -29,6 +29,8 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { AgentService } from '../../services/agent.service';
import { StepReplayFrame } from '../../core/models/stream.model';
import { drawActionCoordinatesOnOverlay } from '../../utils/image-overlay.util';
import { getActionIcon } from '../../utils/action-formatter.util';
@Component({
selector: 'app-floating-video-player',
@@ -41,6 +43,8 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
public agentService = inject(AgentService);
@ViewChild('videoRef') videoRef?: ElementRef<HTMLVideoElement>;
@ViewChild('stepImgRef') stepImgRef?: ElementRef<HTMLImageElement>;
@ViewChild('stepOverlayRef') stepOverlayRef?: ElementRef<HTMLElement>;
// Playback state signals
public isPlaying = signal<boolean>(false);
@@ -65,6 +69,12 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
// Step Replay state signals
public activeStepIndex = signal<number>(0);
public isStepPlaying = signal<boolean>(false);
public viewMode = signal<'pre' | 'post'>('pre'); // 'pre' = action preview with coords, 'post' = action result
public isCardHovered = signal<boolean>(false);
public isCardPinned = signal<boolean>(false);
public isCardVisible = computed(() => this.isCardHovered() || this.isCardPinned());
public isStepImageLoading = signal<boolean>(false);
public stepImageError = signal<boolean>(false);
private stepTimer: any = null;
public stepFrames = computed(() => this.agentService.currentSessionStepFrames());
@@ -74,6 +84,14 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
const idx = this.activeStepIndex();
return frames[idx] || null;
});
public currentStepImageUrl = computed<string>(() => {
const frame = this.currentStepFrame();
if (!frame) return '';
if (this.viewMode() === 'post' && frame.postImageUrl) {
return frame.postImageUrl;
}
return frame.preImageUrl || frame.imageUrl || '';
});
public stepProgressPercent = computed(() => {
const total = this.totalStepFrames();
if (total <= 1) return 0;
@@ -85,10 +103,10 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
// Dragging and window positioning state
public posX = signal<number>(
typeof window !== 'undefined' ? Math.max(20, window.innerWidth - 380) : 100
typeof window !== 'undefined' ? Math.max(20, window.innerWidth - 420) : 100
);
public posY = signal<number>(
typeof window !== 'undefined' ? Math.max(60, window.innerHeight - 660) : 100
typeof window !== 'undefined' ? Math.max(60, window.innerHeight - 700) : 100
);
private isDragging = false;
@@ -117,11 +135,30 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
() => {
const _ = this.stepFrames();
this.activeStepIndex.set(0);
this.viewMode.set('pre');
this.pauseStepPlay();
},
{ allowSignalWrites: true }
);
// Track step screenshot URL changes and reset loading/error state
effect(() => {
const url = this.currentStepImageUrl();
if (url) {
this.isStepImageLoading.set(true);
this.stepImageError.set(false);
} else {
this.isStepImageLoading.set(false);
}
}, { allowSignalWrites: true });
// Re-draw overlay whenever activeStepIndex or viewMode changes
effect(() => {
this.activeStepIndex();
this.viewMode();
setTimeout(() => this.drawStepOverlay(), 40);
});
effect(() => {
const request = this.agentService.videoSeekRequest();
if (!request) return;
@@ -444,15 +481,18 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
public prevStepFrame(): void {
this.pauseStepPlay();
this.viewMode.set('pre');
this.activeStepIndex.update((i) => Math.max(0, i - 1));
}
public nextStepFrame(): void {
this.pauseStepPlay();
this.viewMode.set('pre');
this.activeStepIndex.update((i) => Math.min(this.totalStepFrames() - 1, i + 1));
}
public seekStepFrame(index: number): void {
this.viewMode.set('pre');
this.activeStepIndex.set(Math.max(0, Math.min(this.totalStepFrames() - 1, index)));
}
@@ -466,8 +506,75 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
this.startStepPlay();
}
public setStepViewMode(mode: 'pre' | 'post', event?: Event): void {
if (event) event.stopPropagation();
this.viewMode.set(mode);
setTimeout(() => this.drawStepOverlay(), 40);
}
public toggleStepViewMode(event?: Event): void {
if (event) event.stopPropagation();
this.setStepViewMode(this.viewMode() === 'pre' ? 'post' : 'pre');
}
public onViewportMouseEnter(): void {
this.isCardHovered.set(true);
}
public onViewportMouseLeave(): void {
this.isCardHovered.set(false);
}
public toggleCardPin(event?: Event): void {
if (event) event.stopPropagation();
this.isCardPinned.update((v) => !v);
}
public cycleSpeed(): void {
const current = this.playbackRate();
const idx = this.speedOptions.indexOf(current);
const nextIdx = (idx + 1) % this.speedOptions.length;
this.setSpeed(this.speedOptions[nextIdx]);
}
public onStepImageLoad(): void {
this.isStepImageLoading.set(false);
this.stepImageError.set(false);
this.drawStepOverlay();
}
public onStepImageError(): void {
this.isStepImageLoading.set(false);
this.stepImageError.set(true);
}
public drawStepOverlay(): void {
const img = this.stepImgRef?.nativeElement;
const overlay = this.stepOverlayRef?.nativeElement;
if (!img || !overlay) return;
overlay.innerHTML = '';
// Only draw coordinate overlays when viewing the pre-action screenshot
if (this.viewMode() === 'post') {
return;
}
const frame = this.currentStepFrame();
if (!frame || !frame.action) return;
try {
drawActionCoordinatesOnOverlay(img, overlay, frame.action);
} catch (err) {
console.warn('Failed to draw step coordinates overlay:', err);
}
}
public getStepActionIcon(action: any): string {
return getActionIcon(action);
}
public openImageInNewTab(): void {
const url = this.currentStepFrame()?.imageUrl;
const url = this.currentStepImageUrl() || this.currentStepFrame()?.imageUrl;
if (url) {
window.open(url, '_blank');
}
@@ -35,7 +35,7 @@
*ngFor="let session of agentService.sessions()"
[value]="session.session_id"
>
{{ session.initial_goal || 'Unnamed Goal' }} ({{ session.start_time * 1000 | date:'short' }})
{{ session.initial_goal || 'Unnamed Goal' }} ({{ session.device_serial ? session.device_serial + ' · ' : '' }}{{ session.start_time * 1000 | date:'short' }})
</option>
</select>
<button class="btn-refresh" (click)="agentService.fetchSessions()">🔄 Refresh</button>
@@ -247,12 +247,12 @@ export const SMART_TASK_LIBRARY: SmartSuggestion[] = [
priority: 82
},
// 9. WeChat (微信)
// 9. WeChat
{
id: 'wechat_browse',
title: 'Check WeChat Messages',
description: 'Open WeChat and view top recent chat conversations',
goal: 'Open WeChat (微信) and view the top recent chat messages.',
goal: 'Open WeChat and view the top recent chat messages.',
profile: 'flash',
category: 'flash',
tag: 'WeChat',
@@ -264,7 +264,7 @@ export const SMART_TASK_LIBRARY: SmartSuggestion[] = [
id: 'pro_wechat_to_calendar',
title: 'WeChat Notice to Calendar',
description: 'Extract meeting notice from WeChat chat and add to Calendar',
goal: 'Open WeChat (微信), locate the latest meeting announcement or event message in the top chat, extract the time and topic, then open Calendar and schedule an event.',
goal: 'Open WeChat, locate the latest meeting announcement or event message in the top chat, extract the time and topic, then open Calendar and schedule an event.',
profile: 'pro',
category: 'cross_app',
tag: 'WeChat + Calendar',
@@ -276,12 +276,12 @@ export const SMART_TASK_LIBRARY: SmartSuggestion[] = [
priority: 93
},
// 10. Xiaohongshu (小红书)
// 10. Xiaohongshu
{
id: 'xhs_coffee_guide',
title: 'RED Cafe Guide Search',
description: 'Search trending specialty cafe reviews on Xiaohongshu',
goal: 'Open Xiaohongshu (小红书), search for top-rated specialty coffee shops, and view the top post.',
goal: 'Open Xiaohongshu, search for top-rated specialty coffee shops, and view the top post.',
profile: 'flash',
category: 'flash',
tag: 'Xiaohongshu',
@@ -290,12 +290,12 @@ export const SMART_TASK_LIBRARY: SmartSuggestion[] = [
priority: 87
},
// 11. Meituan / Dianping (美团 / 大众点评)
// 11. Meituan / Dianping
{
id: 'meituan_ramen_search',
title: 'Meituan Food Search',
description: 'Search top-rated Ramen nearby on Meituan or Dianping',
goal: 'Open Meituan (美团) or Dianping (大众点评), search for top-rated Ramen nearby, and view top restaurant rating.',
goal: 'Open Meituan or Dianping, search for top-rated Ramen nearby, and view top restaurant rating.',
profile: 'flash',
category: 'flash',
tag: 'Meituan',
@@ -304,12 +304,12 @@ export const SMART_TASK_LIBRARY: SmartSuggestion[] = [
priority: 86
},
// 12. Bilibili (哔哩哔哩)
// 12. Bilibili
{
id: 'bilibili_stream',
title: 'Bilibili Tech Video',
description: 'Search and play an AI Agent tutorial video on Bilibili',
goal: 'Open Bilibili (哔哩哔哩), search for "AI Agent 架构实战", and play the top matching video.',
goal: 'Open Bilibili, search for "AI Agent Architecture", and play the top matching video.',
profile: 'flash',
category: 'flash',
tag: 'Bilibili',
@@ -28,6 +28,8 @@ export interface TaskQueueItem {
status: 'pending' | 'running' | 'paused' | 'completed' | 'failed' | 'cancelled';
created_at?: number;
start_time?: number;
device_serial?: string | null;
device_id?: string | null;
}
export interface Session {
@@ -39,6 +41,9 @@ export interface Session {
video_url?: string;
recording_status?: 'recording' | 'finalizing' | 'processing' | 'ready' | 'failed' | 'unavailable';
model_info?: ModelInfo;
device_serial?: string | null;
device_id?: string | null;
device_info?: any;
}
export interface AgentStatusResponse {
@@ -101,9 +101,17 @@ export interface CheckerResult {
export interface StepReplayFrame {
index: number;
stepNumber: number;
stepId?: string;
title: string;
imageUrl: string;
preImageUrl?: string | null;
postImageUrl?: string | null;
action?: any;
actionType?: string;
actionText?: string;
targetText?: string;
coords?: string;
status?: 'success' | 'failed' | string;
isPost?: boolean;
timestamp?: number;
phaseId?: string;
@@ -68,6 +68,9 @@ export class AgentService {
// Signals to expose state to components
private rawSessions = signal<Session[]>([]);
private pendingQueue = signal<Session[]>([]);
public activeTasks = signal<any[]>([]);
// Persistent tracking of active/pending sessions across polling boundaries
private activeSessionTracking = new Map<string, Session>();
public sessions = computed(() => {
const raw = this.rawSessions();
@@ -75,47 +78,111 @@ export class AgentService {
const status = this.agentStatus();
const goal = this.runningGoal();
const runId = this.runningSessionId();
const activeList = this.activeTasks();
const sessionMap = new Map<string, Session>();
// 1. First add raw sessions from DB
raw.forEach((s) => {
const isCurrentActive = (status === 'running' || status === 'paused') && runId === s.session_id;
const isPending = this.pendingQueue().some(p => p.session_id === s.session_id) && !isCurrentActive;
let sStatus = isCurrentActive ? status : (isPending ? 'pending' : s.status);
if ((sStatus === 'running' || sStatus === 'paused') && !isCurrentActive) {
const activeRun = raw.find(r => r.session_id === runId);
sStatus = (activeRun && (s.start_time || 0) > (activeRun.start_time || 0)) ? 'pending' : 'completed';
const isCurrentActive = ((status === 'running' || status === 'paused') && runId === s.session_id)
|| activeList.some(at => at.session_id === s.session_id);
const isTerminal = s.status === 'completed' || s.status === 'success' || s.status === 'failed' || s.status === 'cancelled';
let sStatus = s.status;
if (!isTerminal) {
const isPending = this.pendingQueue().some(p => p.session_id === s.session_id) && !isCurrentActive;
sStatus = isCurrentActive ? (status === 'paused' ? 'paused' : 'running') : (isPending ? 'pending' : s.status);
} else {
sStatus = (s.status === 'success') ? 'completed' : s.status;
}
sessionMap.set(s.session_id, {
const activeMatch = activeList.find(at => at.session_id === s.session_id);
const pendingMatch = this.pendingQueue().find(p => p.session_id === s.session_id);
let serial = s.device_serial || s.device_id || activeMatch?.device_id || pendingMatch?.device_serial || null;
if (!serial && s.device_info) {
try {
const info = typeof s.device_info === 'string' ? JSON.parse(s.device_info) : s.device_info;
serial = info?.device_id || info?.device_serial || null;
} catch {
// ignore
}
}
const finalSession: Session = {
...s,
status: sStatus,
device_serial: serial,
model_info: isCurrentActive && this.activeModel() ? this.activeModel()! : s.model_info
});
};
sessionMap.set(s.session_id, finalSession);
if (isTerminal) {
this.activeSessionTracking.delete(s.session_id);
} else if (sStatus === 'running' || sStatus === 'paused' || sStatus === 'pending') {
this.activeSessionTracking.set(s.session_id, finalSession);
}
});
// 2. Add pending queue sessions if not yet in raw sessions
pending.forEach((p) => {
if (!sessionMap.has(p.session_id)) {
const isCurrentRunning = (status === 'running' || status === 'paused') && runId === p.session_id;
sessionMap.set(p.session_id, {
const isCurrentRunning = ((status === 'running' || status === 'paused') && runId === p.session_id)
|| activeList.some(at => at.session_id === p.session_id);
const activeMatch = activeList.find(at => at.session_id === p.session_id);
const finalPending: Session = {
...p,
status: isCurrentRunning ? status : (p.status || 'pending')
});
status: isCurrentRunning ? (status === 'paused' ? 'paused' : 'running') : (p.status || 'pending'),
device_serial: p.device_serial || p.device_id || activeMatch?.device_id || null
};
sessionMap.set(p.session_id, finalPending);
this.activeSessionTracking.set(p.session_id, finalPending);
}
});
// 3. Ensure currently active running session is present
if ((status === 'running' || status === 'paused') && runId && !sessionMap.has(runId)) {
sessionMap.set(runId, {
// 3. Ensure all currently active running sessions are present (multi-device & external runs)
if (activeList.length > 0) {
activeList.forEach((at) => {
const sid = at.session_id || `active-${at.device_id}`;
const existing = sessionMap.get(sid);
if (!existing) {
const newSession: Session = {
session_id: sid,
initial_goal: at.goal || goal || '',
start_time: at.acquired_at ? (new Date(at.acquired_at).getTime() / 1000) : (Date.now() / 1000),
status: 'running',
model_info: this.activeModel() || undefined,
device_serial: at.device_id || null
};
sessionMap.set(sid, newSession);
this.activeSessionTracking.set(sid, newSession);
} else if (existing.status === 'pending') {
const updated: Session = {
...existing,
status: 'running',
device_serial: at.device_id || existing.device_serial
};
sessionMap.set(sid, updated);
this.activeSessionTracking.set(sid, updated);
}
});
} else if ((status === 'running' || status === 'paused') && runId && !sessionMap.has(runId)) {
const activeSession: Session = {
session_id: runId,
initial_goal: goal || '',
start_time: Date.now() / 1000,
status,
model_info: this.activeModel() || undefined
});
};
sessionMap.set(runId, activeSession);
this.activeSessionTracking.set(runId, activeSession);
}
// 4. Bridge transient queue-to-running transition gaps
this.activeSessionTracking.forEach((ts, sid) => {
if (!sessionMap.has(sid)) {
sessionMap.set(sid, {
...ts,
status: 'running'
});
}
});
return Array.from(sessionMap.values()).sort((a, b) => b.start_time - a.start_time);
});
@@ -137,8 +204,14 @@ export class AgentService {
public currentStartupProgress = computed(() => {
const sessionId = this.currentSessionId();
if (sessionId) {
const sessionEvents = this.startupProgressBySession()[sessionId];
if (sessionEvents?.length) return sessionEvents;
const normalized = String(sessionId).trim().toLowerCase();
const bySession = this.startupProgressBySession();
const direct = bySession[sessionId];
if (direct?.length) return direct;
const matched = Object.entries(bySession).find(
([key]) => key.trim().toLowerCase() === normalized
);
if (matched?.[1]?.length) return matched[1];
}
return this.pendingStartupProgress();
});
@@ -609,7 +682,10 @@ export class AgentService {
return;
}
if (evtSessionId && String(evtSessionId) !== String(sessionId)) {
const isDifferentSession = evtSessionId
&& String(evtSessionId).trim().toLowerCase() !== String(sessionId).trim().toLowerCase();
if (isDifferentSession) {
if (eventType === 'session_started') {
this.agentStatus.set('running');
this.runningSessionId.set(parsedData.session_id);
@@ -617,13 +693,30 @@ export class AgentService {
this.runningGoal.set(parsedData.initial_goal);
}
this.fetchSessions();
// Only auto-select if user hasn't explicitly chosen to inspect another task
const pinnedId = this.userPinnedSessionId();
if (!pinnedId && parsedData?.session_id) {
// Only auto-select if user has no current session at all
const currentId = this.currentSessionId();
if (!currentId && parsedData?.session_id) {
this.selectSession(parsedData.session_id, false);
}
} else if (eventType === 'session_ended') {
this.applySessionEndedStatus(evtSessionId, parsedData);
this.activeTasks.update(list => list.filter(at => at.session_id !== evtSessionId));
const remaining = this.activeTasks();
if (remaining.length > 0) {
if (this.runningSessionId() === evtSessionId) {
this.runningSessionId.set(remaining[0].session_id);
this.runningGoal.set(remaining[0].goal || null);
}
} else if (this.pendingQueue().length > 0) {
const nextPending = this.pendingQueue()[0];
this.agentStatus.set('running');
this.runningSessionId.set(nextPending.session_id);
this.runningGoal.set(nextPending.initial_goal || null);
} else {
this.agentStatus.set('idle');
this.runningSessionId.set(null);
this.runningGoal.set(null);
}
this.fetchStatus();
this.fetchSessions();
this.updateModelForCurrentView();
@@ -650,12 +743,26 @@ export class AgentService {
if (eventType === 'session_ended') {
this.applySessionEndedStatus(sessionId, parsedData);
this.agentStatus.set('idle');
this.runningSessionId.set(null);
this.runningGoal.set(null);
this.activeTasks.update(list => list.filter(at => at.session_id !== sessionId));
const remaining = this.activeTasks();
if (remaining.length > 0) {
this.agentStatus.set('running');
this.runningSessionId.set(remaining[0].session_id);
this.runningGoal.set(remaining[0].goal || null);
} else if (this.pendingQueue().length > 0) {
const nextPending = this.pendingQueue()[0];
this.agentStatus.set('running');
this.runningSessionId.set(nextPending.session_id);
this.runningGoal.set(nextPending.initial_goal || null);
} else {
this.agentStatus.set('idle');
this.runningSessionId.set(null);
this.runningGoal.set(null);
}
this.isRetrying.set(false);
this.isPaused.set(false);
this.fetchSessions();
this.fetchStatus();
this.fetchNotes(sessionId);
this.updateModelForCurrentView();
if (
@@ -1072,7 +1179,9 @@ export class AgentService {
: null;
if (status) {
this.activeSessionTracking.delete(String(sessionId));
this.setSessionStatus(String(sessionId), status);
this.activeTasks.update((list) => list.filter((at) => at.session_id !== String(sessionId)));
}
}
@@ -1100,34 +1209,15 @@ export class AgentService {
if (data && data.status) {
const oldStatus = this.agentStatus();
const oldRunningSessionId = this.runningSessionId();
this.agentStatus.set(data.status);
const isActive = data.status === 'running' || data.status === 'paused';
if (isActive) {
this.runningSessionId.set(data.session_id || null);
this.runningGoal.set(data.goal || null);
if (data.model_info) {
this.activeModel.set(data.model_info);
}
} else {
this.runningSessionId.set(null);
this.runningGoal.set(null);
// If viewing a selected history session, use its model_info
const curId = this.currentSessionId();
if (curId) {
const selected = this.sessions().find(s => s.session_id === curId);
if (selected && selected.model_info) {
this.activeModel.set(selected.model_info);
} else if (data.model_info) {
this.activeModel.set(data.model_info);
}
} else if (data.model_info) {
this.activeModel.set(data.model_info);
}
this.agentStatus.set(data.status);
this.runningSessionId.set(data.session_id || null);
this.runningGoal.set(data.goal || null);
if (data.model_info) {
this.activeModel.set(data.model_info);
}
this.isPaused.set(data.status === 'paused');
if (data.status === 'paused') {
this.isRetrying.set(false);
const pauseError = data.paused_error
@@ -1149,7 +1239,8 @@ export class AgentService {
session_id: item.session_id || `pending-task-${index}`,
initial_goal: item.goal || '',
start_time: item.start_time || item.created_at || (Date.now() / 1000 + index),
status: item.status || 'pending'
status: item.status || 'pending',
device_serial: item.device_serial || item.device_id || null
};
}
return {
@@ -1160,6 +1251,7 @@ export class AgentService {
};
});
this.pendingQueue.set(pending);
this.activeTasks.set(data.active_tasks || []);
if (oldStatus !== data.status || oldRunningSessionId !== data.session_id) {
this.fetchSessions();
@@ -1176,16 +1268,11 @@ export class AgentService {
this.beginRecordingFinalization(oldRunningSessionId);
}
// Auto-select the session if running and user hasn't explicitly chosen to inspect another task
// Auto-select the session ONLY if nothing is currently selected
if (isActive && data.session_id) {
const currentId = this.currentSessionId();
const pinnedId = this.userPinnedSessionId();
if (!currentId) {
this.selectSession(data.session_id, false);
} else if (currentId !== data.session_id) {
if (!pinnedId) {
this.selectSession(data.session_id, false);
}
}
}
}
@@ -1328,8 +1415,9 @@ export class AgentService {
timestamp
};
const rawKey = String(data.session_id || sessionId).trim();
this.startupProgressBySession.update((allEvents) => {
const current = [...(allEvents[sessionId] || [])];
const current = [...(allEvents[rawKey] || allEvents[sessionId] || [])];
const existingIndex = current.findIndex((item) => item.stage === event.stage);
if (existingIndex >= 0) {
current[existingIndex] = event;
@@ -1337,7 +1425,11 @@ export class AgentService {
current.push(event);
}
current.sort((a, b) => a.timestamp - b.timestamp);
return { ...allEvents, [sessionId]: current };
const updated = { ...allEvents, [rawKey]: current };
if (sessionId && sessionId !== rawKey) {
updated[sessionId] = current;
}
return updated;
});
}
@@ -85,7 +85,7 @@ describe('action-formatter.util screenshot chaining', () => {
expect(act1Pre).toBe('/images/fbdf17fa7a970d2c37aa52ae4336119e76a7d13ea457811cf8aaab19b3f53843');
expect(act1Post).toBe('/images/18bd2ac49e64976f1e74e828ddae76e54da1587044b1d3fb01317dd202ff7c67');
// Action 2: "下一次的决策前是上一次的动作后" -> Pre MUST be Action 1's post (18bd2ac4...), Post should be Action 2's post (484361b1...)
// Action 2: "The next pre-action state is the previous post-action state" -> Pre MUST be Action 1's post (18bd2ac4...), Post should be Action 2's post (484361b1...)
const act2Pre = getStepPreImageUrl(stepData, action2);
const act2Post = getStepPostImageUrl(stepData, action2);
expect(act2Pre).toBe('/images/18bd2ac49e64976f1e74e828ddae76e54da1587044b1d3fb01317dd202ff7c67');
@@ -154,7 +154,7 @@ describe('extractStepReplayFrames', () => {
expect(extractStepReplayFrames(null as any)).toEqual([]);
});
it('should extract ordered StepReplayFrames and deduplicate consecutive identical images', () => {
it('should extract 1:1 ordered StepReplayFrames with action objects and pre/post images', () => {
const logs = [
{
type: 'step_updated',
@@ -181,13 +181,18 @@ describe('extractStepReplayFrames', () => {
];
const frames = extractStepReplayFrames(logs);
expect(frames.length).toBe(3);
expect(frames[0].title).toBe('Step 1 (Before Action)');
expect(frames.length).toBe(2);
expect(frames[0].stepNumber).toBe(1);
expect(frames[0].imageUrl).toBe('/images/img_screen_1');
expect(frames[1].title).toContain('Step 1');
expect(frames[0].preImageUrl).toBe('/images/img_screen_1');
expect(frames[0].postImageUrl).toBe('/images/img_screen_2');
expect(frames[0].action).toEqual({ action: 'tap', coordinates: [500, 1000] });
expect(frames[1].stepNumber).toBe(2);
expect(frames[1].imageUrl).toBe('/images/img_screen_2');
expect(frames[2].title).toContain('Step 2');
expect(frames[2].imageUrl).toBe('/images/img_screen_3');
expect(frames[1].preImageUrl).toBe('/images/img_screen_2');
expect(frames[1].postImageUrl).toBe('/images/img_screen_3');
expect(frames[1].action).toEqual({ action: 'input_text', text: 'hello' });
});
it('should handle terminal step with only pre_image (e.g. cancelled / failed task)', () => {
@@ -706,7 +706,7 @@ export function resolveStepImageMap(stepData: any): Map<any, ResolvedImageEntry>
resolvedPost = stepData.post_image_name || stepData.post_screenshot || null;
}
// "下一次的决策前是上一次的动作后"
// "The next pre-action state is the previous post-action state"
if (resolvedPost) {
currentPreImage = resolvedPost;
}
@@ -778,24 +778,37 @@ export function extractStepReplayFrames(logsOrSteps: any[]): StepReplayFrame[] {
const rawSteps: any[] = [];
for (const item of logsOrSteps) {
if (!item) continue;
if (item.type === 'step_updated' && item.data) {
rawSteps.push(item.data);
} else if (item.type === 'step' && item.data) {
if ((item.type === 'step_updated' || item.type === 'step_recorded' || item.type === 'step') && item.data) {
rawSteps.push(item.data);
} else if (item.step_number !== undefined || item.step_id !== undefined) {
rawSteps.push(item);
}
}
// Deduplicate by step_id or step_number, keeping latest
// Deduplicate and merge by step_id or step_number
const stepMap = new Map<any, any>();
for (const step of rawSteps) {
const key = step.step_id || step.step_number;
if (key !== undefined) {
stepMap.set(key, step);
const existing = stepMap.get(key);
if (existing) {
stepMap.set(key, {
...existing,
...step,
step_number: step.step_number !== undefined ? step.step_number : existing.step_number,
pre_image_name: step.pre_image_name || existing.pre_image_name,
post_image_name: step.post_image_name || existing.post_image_name,
action_taken: step.action_taken || existing.action_taken,
last_execution_result: step.last_execution_result || existing.last_execution_result,
generic_tools: step.generic_tools || existing.generic_tools
});
} else {
stepMap.set(key, { ...step });
}
}
}
// Sort strictly by step_number ascending
const steps = Array.from(stepMap.values()).sort((a, b) => {
const numA = Number(a.step_number ?? 0);
const numB = Number(b.step_number ?? 0);
@@ -804,47 +817,45 @@ export function extractStepReplayFrames(logsOrSteps: any[]): StepReplayFrame[] {
});
const frames: StepReplayFrame[] = [];
let lastImageUrl: string | null = null;
for (const stepData of steps) {
const stepNum = Number(stepData.step_number ?? (frames.length + 1));
for (let i = 0; i < steps.length; i++) {
const stepData = steps[i];
const stepNum = Number(stepData.step_number ?? (i + 1));
const act = stepData.action_taken;
const title = act ? (getActionTitle(act) || 'Action') : 'Step';
const title = act ? (getActionTitle(act) || 'Action') : `Step ${stepNum}`;
const coords = act ? getActionCoords(act) : '';
const actionDesc = coords ? `${title} (${coords})` : title;
const targetText = act ? getActionTargetText(act) : '';
const actionDesc = coords ? `${title} (${coords})` : (targetText ? `${title} (${targetText})` : title);
const preUrl = getStepPreImageUrl(stepData, act);
const postUrl = getStepPostImageUrl(stepData, act);
// If pre-image exists and is new, add it
if (preUrl && preUrl !== lastImageUrl) {
frames.push({
index: frames.length,
stepNumber: stepNum,
title: `Step ${stepNum} (Before Action)`,
imageUrl: preUrl,
actionText: `Preparing: ${actionDesc}`,
isPost: false,
timestamp: stepData.timestamp
});
lastImageUrl = preUrl;
// Each step gets exactly one primary frame (Pre-action screenshot where the action is taken)
const primaryImg = preUrl || postUrl;
if (!primaryImg) {
continue;
}
// If post-image exists and is different from current screen, add it
if (postUrl && postUrl !== lastImageUrl) {
frames.push({
index: frames.length,
stepNumber: stepNum,
title: `Step ${stepNum}: ${actionDesc}`,
imageUrl: postUrl,
actionText: actionDesc,
isPost: true,
timestamp: stepData.timestamp
});
lastImageUrl = postUrl;
}
const failed = isActionFailed(act, stepData);
frames.push({
index: frames.length,
stepNumber: stepNum,
stepId: String(stepData.step_id || `step-${stepNum}`),
title: `Step ${stepNum}: ${title}`,
actionText: actionDesc,
action: act,
actionType: act?.action || act?.name || 'action',
targetText,
coords,
imageUrl: primaryImg,
preImageUrl: preUrl,
postImageUrl: postUrl,
isPost: false,
timestamp: stepData.timestamp,
status: failed ? 'failed' : 'success'
});
}
return frames;
}
+2 -1
View File
@@ -16,7 +16,7 @@
__version__ = "0.2.0"
from artemis.interfaces.sdk.client import ArtemisClient
from artemis.interfaces.sdk.client import ArtemisClient, ConcurrencyMode
from artemis.interfaces.sdk.task import StreamEvent, StreamEventType, Task, TaskResult
from artemis.sdk.agent import Agent
from artemis.sdk.builders import Builders
@@ -27,6 +27,7 @@ from artemis.tools.base import artemis_tool, ToolRegistry
__all__ = [
"__version__",
"ArtemisClient",
"ConcurrencyMode",
"StreamEvent",
"StreamEventType",
"Agent",
+29
View File
@@ -218,6 +218,7 @@ class FlashRunner:
for attempt in range(max_retries):
try:
stream_exec_id = uuid.uuid4()
async def run_stream():
full_response = None
@@ -226,6 +227,34 @@ class FlashRunner:
full_response = chunk
else:
full_response += chunk
# Real-time token streaming to DataEngine for live UI display
if (
getattr(self, "ctx", None)
and getattr(self.ctx, "data_engine", None)
and getattr(chunk, "content", None)
):
text_to_stream = ""
thinking_to_stream = ""
if isinstance(chunk.content, str):
text_to_stream = chunk.content
elif isinstance(chunk.content, list):
for item in chunk.content:
if isinstance(item, str):
text_to_stream += item
elif isinstance(item, dict):
if item.get("type") == "text":
text_to_stream += item.get("text", "")
elif item.get("type") == "thinking":
thinking_to_stream += item.get("thinking", "")
if text_to_stream:
self.ctx.data_engine.stream_output(
stream_exec_id, text_to_stream, is_thinking=False
)
if thinking_to_stream:
self.ctx.data_engine.stream_output(
stream_exec_id, thinking_to_stream, is_thinking=True
)
return full_response
response = await invoke_llm_with_timeout_message(
+1
View File
@@ -84,6 +84,7 @@ AGENT_CONFIG_FILENAME = "agent_config.json"
DATA_ENGINE_DB_FILENAME = "data_engine.db"
IPC_PORT_FILENAME = ".artemis_ipc_port"
LS_ADDRESS_FILENAME = ".jetski_ls_address"
SERVER_INFO_FILENAME = ".artemis_server.json"
DOTENV_FILENAME = ".env"
+9
View File
@@ -26,6 +26,7 @@ from artemis.config.constants import (
ENV_ARTEMIS_USE_USER_DIR,
IPC_PORT_FILENAME,
LS_ADDRESS_FILENAME,
SERVER_INFO_FILENAME,
)
from artemis.platform import platform
@@ -110,6 +111,14 @@ def get_ls_address_file() -> Path:
return ROOT_DIR / LS_ADDRESS_FILENAME
def get_server_info_file() -> Path:
"""Returns the location of the Artemis server metadata file."""
app_dir_file = get_app_dir() / SERVER_INFO_FILENAME
if app_dir_file.exists():
return app_dir_file
return ROOT_DIR / SERVER_INFO_FILENAME
def get_config_path(filename: str, default_bundled_path: Path | None = None) -> Path:
"""Resolve a configuration file path across env vars, config dir, project dir, app dir, and bundles.
+60 -20
View File
@@ -21,8 +21,11 @@ import time
from artemis.config.constants import (
ENV_ANTIGRAVITY_LS_ADDRESS,
ENV_ARTEMIS_IPC_PORT,
IPC_PORT_FILENAME,
)
from artemis.config.paths import (
ROOT_DIR,
get_app_dir,
get_ipc_port_file,
get_ls_address_file,
get_temp_dir,
@@ -33,19 +36,43 @@ logger = get_logger(__name__)
def read_ipc_port() -> int | None:
"""Read the active ARTEMIS IPC port from environment or temporary state file."""
"""Read the active ARTEMIS IPC port from environment, state files, or local server API."""
env_port = os.getenv(ENV_ARTEMIS_IPC_PORT)
if env_port and env_port.strip().isdigit():
return int(env_port.strip())
port_file = get_ipc_port_file()
if port_file.exists():
try:
content = port_file.read_text(encoding="utf-8").strip()
if content.isdigit():
return int(content)
except Exception as e:
logger.warning(f"Failed to read IPC port from {port_file}: {e}")
candidate_paths = [
get_temp_dir() / "artemis-ipc-port",
get_app_dir() / IPC_PORT_FILENAME,
ROOT_DIR / IPC_PORT_FILENAME,
get_ipc_port_file(),
]
for p in candidate_paths:
if p.exists():
try:
content = p.read_text(encoding="utf-8").strip()
if content.isdigit():
return int(content)
except Exception:
pass
# Dynamic fallback: check local admin console status endpoint
try:
import urllib.request
import json
req = urllib.request.Request(
"http://127.0.0.1:8000/api/status",
headers={"User-Agent": "Artemis-IPC-Discovery"},
)
with urllib.request.urlopen(req, timeout=0.2) as resp:
if resp.status == 200:
data = json.loads(resp.read().decode("utf-8"))
ipc_port = data.get("ipc_port")
if ipc_port and str(ipc_port).isdigit():
return int(ipc_port)
except Exception:
pass
return None
@@ -54,23 +81,36 @@ def write_ipc_port(port: int) -> Path:
"""Save active IPC port to environment and temporary synchronization file."""
os.environ[ENV_ARTEMIS_IPC_PORT] = str(port)
port_file = get_ipc_port_file()
try:
port_file.parent.mkdir(parents=True, exist_ok=True)
port_file.write_text(str(port), encoding="utf-8")
except Exception as e:
logger.warning(f"Failed to write IPC port file to {port_file}: {e}")
all_targets = [
port_file,
get_temp_dir() / "artemis-ipc-port",
get_app_dir() / IPC_PORT_FILENAME,
ROOT_DIR / IPC_PORT_FILENAME,
]
for target in all_targets:
try:
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(str(port), encoding="utf-8")
except Exception as e:
logger.debug(f"Could not write IPC port to {target}: {e}")
return port_file
def clear_ipc_port() -> None:
"""Remove IPC port synchronization state from the process and filesystem."""
os.environ.pop(ENV_ARTEMIS_IPC_PORT, None)
port_file = get_ipc_port_file()
if port_file.exists():
try:
port_file.unlink(missing_ok=True)
except Exception as e:
logger.warning(f"Failed to remove IPC port file {port_file}: {e}")
all_targets = [
get_ipc_port_file(),
get_temp_dir() / "artemis-ipc-port",
get_app_dir() / IPC_PORT_FILENAME,
ROOT_DIR / IPC_PORT_FILENAME,
]
for target in all_targets:
if target.exists():
try:
target.unlink(missing_ok=True)
except Exception as e:
logger.debug(f"Failed to remove IPC port file {target}: {e}")
def read_ls_address() -> str | None:
+5 -5
View File
@@ -75,12 +75,12 @@ class DeviceContext(BaseModel):
extra="allow",
)
host_platform: Literal["WINDOWS", "LINUX", "DARWIN", "MACOS"] | str
mobile_platform: DevicePlatform
device_id: str
host_platform: Literal["WINDOWS", "LINUX", "DARWIN", "MACOS"] | str = "DARWIN"
mobile_platform: DevicePlatform = DevicePlatform.ANDROID
device_id: str = "default-device"
device_width: int
device_height: int
device_width: int = 1080
device_height: int = 2400
def to_str(self):
return (
@@ -44,7 +44,17 @@ def get_adb_device(ctx: ArtemisContext) -> Any:
def get_first_device(
logger: ArtemisLogger | None = None,
) -> tuple[str | None, DevicePlatform | None, None]:
"""Gets the first available device."""
"""Gets the first available device, prioritizing idle and unassigned devices."""
try:
from artemis.runtime import device_pool
chosen = device_pool.select_device()
if chosen:
return chosen, DevicePlatform.ANDROID, None
except Exception as exc:
if logger:
logger.debug(f"Device pool selection fallback: {exc}")
if which("adb"):
try:
android_output = run_shell_command_on_host("adb devices")
+20 -8
View File
@@ -222,19 +222,31 @@ class DataEngine:
self._send_ipc_event(event_type, data)
def start_session(self, goal: str, device_info: dict[str, Any] | None = None) -> UUID:
def start_session(
self,
goal: str,
device_info: dict[str, Any] | None = None,
session_id: UUID | str | None = None,
) -> UUID:
"""Start a new session."""
global _CURRENT_DATA_ENGINE
_CURRENT_DATA_ENGINE = self
env_session_id = os.getenv("ARTEMIS_CLOUD_SESSION_ID") or os.getenv("ARTEMIS_SESSION_ID")
if env_session_id:
try:
session_id = UUID(env_session_id)
except ValueError:
session_id = env_session_id
if session_id is not None:
if isinstance(session_id, str):
try:
session_id = UUID(session_id)
except ValueError:
pass
else:
session_id = uuid4()
env_session_id = os.getenv("ARTEMIS_CLOUD_SESSION_ID") or os.getenv("ARTEMIS_SESSION_ID")
if env_session_id:
try:
session_id = UUID(env_session_id)
except ValueError:
session_id = env_session_id
else:
session_id = uuid4()
self.current_session_id = session_id
self.session_start_time = time.time()
+1 -1
View File
@@ -50,7 +50,7 @@ class GraphRunner(BaseRunner):
artemis_ctx = ArtemisContext(
device=DeviceContext(
host_platform=platform.os_type.name,
device_serial=self.driver.device_id,
device_id=self.driver.device_id,
)
)
graph = await get_graph(artemis_ctx)
+5 -1
View File
@@ -12,6 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import os
from typing import Literal
from artemis.config import OutputConfig, load_agent_config
@@ -50,13 +51,16 @@ class Pipeline:
device_id: str = "default-device",
output_config: OutputConfig | None = None,
enable_outputter: bool | None = None,
locked_package: str | None = None,
**kwargs: Any,
) -> ExecutionContextState:
ctx = ExecutionContext(task_goal=goal, device_id=device_id)
if not driver:
driver = MockDeviceDriver(device_id=device_id)
max_turns = kwargs.get("max_turns", 5)
runner = cls.create_runner(profile=profile, ctx=ctx, driver=driver)
state = await runner.run()
state = await runner.run(max_turns=max_turns)
# Check Outputter configuration
try:
+1 -1
View File
@@ -59,7 +59,7 @@ class ReactiveRunner(BaseRunner):
artemis_ctx = ArtemisContext(
device=DeviceContext(
host_platform=platform.os_type.name,
device_serial=self.driver.device_id,
device_id=self.driver.device_id,
)
)
flash_runner = FlashRunner(
+55 -1
View File
@@ -39,6 +39,8 @@ logger = get_logger(__name__)
async def execute_task(
goal: str,
device_serial: str | None = None,
session_id: str | None = None,
locked_app_package: str | None = None,
test_name: str | None = None,
traces_output_path_str: str | None = None,
@@ -76,6 +78,14 @@ async def execute_task(
explorer_pro_mode: Override Explorer version mode for Pro execution profile.
"""
publish_startup_progress("configuration", "Loading the run configuration")
effective_sid = (
session_id
or os.getenv("ARTEMIS_SESSION_ID")
or os.getenv("ARTEMIS_CLOUD_SESSION_ID")
)
if effective_sid:
os.environ["ARTEMIS_SESSION_ID"] = str(effective_sid)
llm_config = initialize_llm_config()
agent_profile = AgentProfile(name="default", llm_config=llm_config)
config = Builders.AgentConfig.with_default_profile(profile=agent_profile)
@@ -115,13 +125,25 @@ async def execute_task(
if settings.ADB_HOST:
config.with_adb_server(host=settings.ADB_HOST, port=settings.ADB_PORT)
target_serial = device_serial or settings.ADB_DEVICE_SERIAL or os.environ.get("ADB_DEVICE_SERIAL")
if not target_serial:
try:
from artemis.runtime import device_pool
target_serial = device_pool.select_device()
except Exception:
target_serial = None
if target_serial:
from artemis.context import DevicePlatform
config.for_device(DevicePlatform.ANDROID, target_serial)
if graph_config_callbacks:
config.with_graph_config_callbacks(graph_config_callbacks)
agent: Agent | None = None
try:
publish_startup_progress("device_check", "Checking the Android device")
agent = Agent(config=config.build())
agent = Agent(config=config.build(), session_id=effective_sid)
await agent.init(
retry_count=int(os.getenv("ARTEMIS_HEALTH_RETRIES", 5)),
retry_wait_seconds=int(os.getenv("ARTEMIS_HEALTH_DELAY", 2)),
@@ -273,6 +295,28 @@ def run_command(
help="Explorer version mode when running under Pro profile (Operator/Validator).",
),
] = None,
device_serial: Annotated[
str | None,
typer.Option(
"--device-serial",
"-s",
help="Target specific Android device by serial number (e.g. emulator-5554).",
),
] = None,
session_id: Annotated[
str | None,
typer.Option(
"--session-id",
help="Canonical session UUID for trace and stream telemetry.",
),
] = None,
standalone: Annotated[
bool,
typer.Option(
"--standalone",
help="Run in standalone embedded mode without auto-spawning the Artemis Daemon.",
),
] = False,
) -> None:
"""Run an autonomous UI automation task on the connected Android device."""
if with_video_recording_tools:
@@ -280,6 +324,14 @@ def run_command(
console = Console()
# Daemon auto-spawn: By default, ensure Daemon is running in background unless standalone mode requested
if not standalone and os.environ.get("ARTEMIS_STANDALONE") != "1":
try:
from artemis.runtime import ensure_daemon_running
ensure_daemon_running(timeout=1.5)
except Exception as exc:
logger.debug(f"Daemon probe/auto-spawn skipped: {exc}")
adb_client = None
try:
if which("adb"):
@@ -306,6 +358,8 @@ def run_command(
asyncio.run(
execute_task(
goal=goal,
device_serial=device_serial,
session_id=session_id,
locked_app_package=locked_app_package,
test_name=test_name,
traces_output_path_str=traces_path,
@@ -0,0 +1,255 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""CLI commands for Artemis server lifecycle management: restart, stop, and status."""
from __future__ import annotations
import datetime
from typing import Annotated
import webbrowser
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
import typer
from artemis.interfaces.cli.commands.ui import ui_command
from artemis.runtime.daemon_client import spawn_daemon
from artemis.runtime.server_lifecycle import (
find_server_pids,
get_server_status,
stop_server,
)
console = Console()
def _format_uptime(seconds: float | None) -> str:
"""Format seconds into human-friendly duration (e.g. 1h 23m 45s)."""
if seconds is None:
return "Unknown"
sec = int(seconds)
hours, remainder = divmod(sec, 3600)
minutes, remainder_sec = divmod(remainder, 60)
parts = []
if hours > 0:
parts.append(f"{hours}h")
if minutes > 0 or hours > 0:
parts.append(f"{minutes}m")
parts.append(f"{remainder_sec}s")
return " ".join(parts)
def restart_command(
host: Annotated[
str,
typer.Option("--host", "-H", help="Bind host address for the UI server."),
] = "0.0.0.0",
port: Annotated[
int,
typer.Option("--port", "-p", help="Port to run the unified UI server on."),
] = 8000,
open_browser: Annotated[
bool,
typer.Option(
"--open/--no-open",
help="Automatically open the Showcase UI in default web browser.",
),
] = True,
force: Annotated[
bool,
typer.Option(
"--force",
"-f",
help="Force kill previous server immediately without waiting for graceful exit.",
),
] = False,
reload: Annotated[
bool,
typer.Option(
"--reload/--no-reload",
help="Enable uvicorn live auto-reload (for development).",
),
] = False,
daemon: Annotated[
bool,
typer.Option(
"--daemon",
"-d",
help="Run the new server as a detached background process instead of foreground.",
),
] = False,
) -> None:
"""Restart the Artemis Web UI server, terminating any existing instance on the port."""
console.print()
console.print(
Panel(
f"[bold cyan]🔄 Artemis Server Restart[/bold cyan]\n"
f"[dim]Inspecting port {port} and recycling any running Artemis instances...[/dim]",
border_style="cyan",
expand=False,
)
)
# 1. Stop existing server
existing_pids = find_server_pids(port)
if existing_pids:
pid_str = ", ".join(map(str, existing_pids))
console.print(f" [yellow]🛑 Terminating running Artemis instance (PID: {pid_str})...[/yellow]")
else:
console.print(f" [dim]ℹ No active Artemis server found on port {port}.[/dim]")
success, msg, stopped_pids = stop_server(port=port, force=force, timeout=4.0)
if stopped_pids:
console.print(f" [green]✓ {msg}[/green]\n")
elif not success:
console.print(f" [red]⚠ {msg}[/red]\n")
else:
console.print(f" [green]✓ Port {port} is clear and ready.[/green]\n")
# 2. Launch fresh server instance
local_url = f"http://localhost:{port}"
if daemon:
console.print(f" [cyan]🚀 Starting Artemis in detached background daemon mode...[/cyan]")
proc = spawn_daemon(host=host, port=port)
if proc:
console.print(
f" [bold green]✓ Artemis server running in background (PID: {proc.pid})[/bold green]\n"
f" 🌐 [cyan]{local_url}[/cyan]\n"
f" 🛠️ [cyan]{local_url}/admin[/cyan]\n"
)
if open_browser:
webbrowser.open(local_url)
else:
console.print(" [red]❌ Failed to spawn background daemon.[/red]\n")
raise typer.Exit(code=1)
return
# Default foreground mode
console.print(f" [cyan]🚀 Launching unified Artemis Showcase UI in current terminal...[/cyan]\n")
ui_command(host=host, port=port, open_browser=open_browser, reload=reload)
def stop_command(
port: Annotated[
int,
typer.Option("--port", "-p", help="Port of the Artemis server to stop."),
] = 8000,
force: Annotated[
bool,
typer.Option(
"--force",
"-f",
help="Force kill immediately without waiting for graceful shutdown.",
),
] = False,
) -> None:
"""Stop any running Artemis Web UI / server instance on the given port."""
console.print()
existing_pids = find_server_pids(port)
if existing_pids:
pid_str = ", ".join(map(str, existing_pids))
console.print(f" [yellow]🛑 Stopping Artemis server on port {port} (PID: {pid_str})...[/yellow]")
else:
console.print(f" [dim]Scanning port {port}...[/dim]")
success, msg, stopped_pids = stop_server(port=port, force=force, timeout=4.0)
if stopped_pids:
console.print(
Panel(
f"[bold green]✓ Artemis server stopped successfully.[/bold green]\n\n"
f"[dim]{msg}[/dim]",
title="🛑 Server Stopped",
border_style="green",
expand=False,
)
)
elif not success:
console.print(
Panel(
f"[bold red]⚠ Failed to fully stop server on port {port}[/bold red]\n\n{msg}",
title="⚠ Warning",
border_style="red",
expand=False,
)
)
raise typer.Exit(code=1)
else:
console.print(
Panel(
f"[dim]No active Artemis server found on port {port}. Port is free.[/dim]",
title="ℹ Status",
border_style="dim",
expand=False,
)
)
def status_command(
port: Annotated[
int,
typer.Option("--port", "-p", help="Port to query for Artemis server status."),
] = 8000,
) -> None:
"""Check whether the Artemis server is currently running and display runtime metadata."""
console.print()
status = get_server_status(port=port)
if status["running"]:
pids_str = ", ".join(map(str, status["pids"])) if status["pids"] else str(status["active_pid"] or "Unknown")
uptime_str = _format_uptime(status["uptime_seconds"])
metadata = status.get("metadata") or {}
started_at_str = "Unknown"
if metadata.get("started_at"):
try:
dt = datetime.datetime.fromtimestamp(metadata["started_at"])
started_at_str = dt.strftime("%Y-%m-%d %H:%M:%S")
except Exception:
pass
table = Table(show_header=False, box=None, padding=(0, 2))
table.add_row("[bold cyan]Status[/bold cyan]", "[bold green]● ONLINE / RUNNING[/bold green]")
table.add_row("[bold cyan]PID(s)[/bold cyan]", f"[yellow]{pids_str}[/yellow]")
table.add_row("[bold cyan]Port[/bold cyan]", f"[white]{port}[/white]")
table.add_row("[bold cyan]Uptime[/bold cyan]", f"[white]{uptime_str}[/white]")
table.add_row("[bold cyan]Started At[/bold cyan]", f"[dim]{started_at_str}[/dim]")
table.add_row("[bold cyan]Showcase UI[/bold cyan]", f"[cyan]{status['url']}[/cyan]")
table.add_row("[bold cyan]Admin Debug[/bold cyan]", f"[magenta]{status['admin_url']}[/magenta]")
if metadata.get("cwd"):
table.add_row("[bold cyan]Workspace[/bold cyan]", f"[dim]{metadata['cwd']}[/dim]")
console.print(
Panel(
table,
title="🚀 Artemis Server Status",
border_style="green",
expand=False,
)
)
console.print(" [dim]💡 To restart: [bold]artemis restart[/bold] | To stop: [bold]artemis stop[/bold][/dim]\n")
else:
console.print(
Panel(
f"[dim]○ OFFLINE / STOPPED[/dim]\n\n"
f"Port [cyan]{port}[/cyan] is currently free.\n"
f"[dim]No active Artemis server process detected.[/dim]",
title="ℹ Artemis Server Status",
border_style="dim",
expand=False,
)
)
console.print(" [dim]💡 To start: [bold]artemis ui[/bold] or [bold]./start.sh[/bold][/dim]\n")
+48
View File
@@ -95,11 +95,59 @@ def ui_command(
help="Enable uvicorn live auto-reload (for development).",
),
] = False,
restart: Annotated[
bool,
typer.Option(
"--restart",
"-r",
help="Restart server by terminating any existing instance on the port first.",
),
] = False,
) -> None:
"""Launch the unified Artemis Showcase UI & Admin Console in one click."""
console = Console()
local_url = f"http://localhost:{port}"
from artemis.runtime.server_lifecycle import (
find_server_pids,
is_port_in_use,
stop_server,
)
if restart:
console.print(f" [yellow]🔄 Restart requested. Recycling port {port}...[/yellow]")
stop_server(port=port, timeout=4.0)
elif is_port_in_use(port):
pids = find_server_pids(port)
pid_str = f" (PID: {', '.join(map(str, pids))})" if pids else ""
if sys.stdin.isatty():
console.print(f"\n [yellow]⚠ Port {port} is already in use by an active Artemis server{pid_str}.[/yellow]")
console.print(" [dim]Choose an action:[/dim]")
console.print(" [bold]1[/bold] Open browser (default)")
console.print(" [bold]2[/bold] Restart server (stop existing and start here)")
console.print(" [bold]3[/bold] Cancel")
try:
choice = input(" Select [1-3, default 1]: ").strip()
except (KeyboardInterrupt, EOFError):
choice = "1"
if choice == "2":
console.print(f"\n [yellow]🔄 Terminating previous Artemis instance on port {port}...[/yellow]")
stop_server(port=port, timeout=4.0)
elif choice == "3":
return
else:
if open_browser:
webbrowser.open(local_url)
return
else:
console.print(
f"\n [yellow]⚠ Port {port} is already in use by Artemis{pid_str}. "
f"Use '[bold]artemis restart[/bold]' to restart.[/yellow]\n"
)
if open_browser:
webbrowser.open(local_url)
return
console.print()
msg = (
f"[bold cyan]✨ Artemis Autonomous Mobile Agent UI[/bold cyan]\n\n"
+8
View File
@@ -23,6 +23,11 @@ from artemis.interfaces.cli.commands.init import init_command
from artemis.interfaces.cli.commands.mcp import mcp_command
from artemis.interfaces.cli.commands.run import run_command
from artemis.interfaces.cli.commands.server import server_app
from artemis.interfaces.cli.commands.server_lifecycle import (
restart_command,
status_command,
stop_command,
)
from artemis.interfaces.cli.commands.trace import trace_app
from artemis.interfaces.cli.commands.ui import ui_command
from artemis.utils.logger import get_logger
@@ -44,6 +49,9 @@ app = typer.Typer(
app.command(name="ui", help="Launch the unified Showcase UI & Admin Console in your browser.")(
ui_command
)
app.command(name="restart", help="Restart running Artemis Web UI & server.")(restart_command)
app.command(name="stop", help="Stop running Artemis Web UI & server.")(stop_command)
app.command(name="status", help="Display Artemis Web UI & server status.")(status_command)
app.command(name="run", help="Execute an autonomous task on a mobile device.")(run_command)
app.command(name="init", help="Interactive quickstart wizard to configure API keys & device.")(
init_command
+5 -2
View File
@@ -14,11 +14,14 @@
"""Python SDK for ARTEMIS."""
from artemis.interfaces.sdk.client import ArtemisClient
from artemis.interfaces.sdk.task import Task, TaskResult
from artemis.interfaces.sdk.client import ArtemisClient, ConcurrencyMode
from artemis.interfaces.sdk.task import StreamEvent, StreamEventType, Task, TaskResult
__all__ = [
"ArtemisClient",
"ConcurrencyMode",
"StreamEvent",
"StreamEventType",
"Task",
"TaskResult",
]
+307 -81
View File
@@ -12,10 +12,21 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import asyncio
import os
import uuid
from typing import Any, Literal
from collections.abc import AsyncGenerator, Callable
from artemis.engine.pipeline import Pipeline
from artemis.interfaces.sdk.task import StreamEvent, StreamEventType, TaskResult
from artemis.interfaces.sdk.task import StreamEvent, StreamEventType, Task, TaskResult
from artemis.runtime import (
ConcurrencyMode,
DeviceBusyError,
DeviceExecutionLock,
DeviceStatus,
device_pool,
ensure_daemon_running,
)
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
@@ -25,123 +36,338 @@ class ArtemisClient:
"""The official high-level developer entrypoint for interacting with ARTEMIS."""
def __init__(
self, device_id: str = "default-device", default_profile: Literal["flash", "pro"] = "pro"
self,
device_id: str | None = None,
device_serial: str | None = None,
default_profile: Literal["flash", "pro"] = "pro",
concurrency_mode: Literal["global", "per_device"] | str = "per_device",
max_concurrency: int | None = None,
standalone: bool = False,
):
self.device_id = device_id
self.standalone = standalone or os.environ.get("ARTEMIS_STANDALONE", "").lower() in ("1", "true", "yes")
if not self.standalone:
try:
ensure_daemon_running(timeout=1.5)
except Exception:
pass
target_dev = device_serial or device_id
if target_dev is None or target_dev == "default-device":
self._device_id = device_pool.select_device() or "default-device"
else:
self._device_id = target_dev
self.default_profile = default_profile
self._concurrency_mode = str(concurrency_mode).strip().lower()
self._max_concurrency = max_concurrency
@property
def device_id(self) -> str:
return self._device_id
@device_id.setter
def device_id(self, val: str) -> None:
self._device_id = val
@property
def device_serial(self) -> str:
"""Alias for device_id."""
return self._device_id
@device_serial.setter
def device_serial(self, val: str) -> None:
self._device_id = val
def set_device(self, device_serial: str) -> "ArtemisClient":
"""Set target device serial and return client for chaining."""
self._device_id = device_serial
return self
@property
def concurrency_mode(self) -> str:
"""Concurrency mode: 'global' (1 across all devices) or 'per_device' (1 per device)."""
return self._concurrency_mode
@concurrency_mode.setter
def concurrency_mode(self, mode: Literal["global", "per_device"] | str) -> None:
self._concurrency_mode = str(mode).strip().lower()
@property
def max_concurrency(self) -> int | None:
return self._max_concurrency
@max_concurrency.setter
def max_concurrency(self, val: int | None) -> None:
self._max_concurrency = val
def set_concurrency_mode(self, mode: Literal["global", "per_device"] | str) -> "ArtemisClient":
"""Set concurrency mode ('global' or 'per_device') and return client for chaining."""
self.concurrency_mode = mode
return self
def list_devices(self) -> list[DeviceStatus]:
"""List all connected Android devices with their lock state and availability."""
return device_pool.list_devices()
async def list_devices_async(self) -> list[DeviceStatus]:
"""Asynchronously list all connected Android devices with their lock state."""
return await device_pool.list_devices_async()
def get_idle_devices(self) -> list[DeviceStatus]:
"""Return connected devices currently ready and idle (not holding a task lock)."""
return device_pool.get_idle_devices()
async def run(
self,
goal: str,
profile: Literal["flash", "pro"] | None = None,
device_id: str | None = None,
device_serial: str | None = None,
concurrency_mode: Literal["global", "per_device"] | str | None = None,
max_concurrency: int | None = None,
blocking: bool = True,
lock_timeout: float | None = None,
max_turns: int = 30,
**kwargs: Any,
) -> TaskResult:
"""Executes a single autonomous task and returns the final TaskResult."""
"""Executes a single autonomous task on a target device and returns the final TaskResult."""
target_profile = profile or self.default_profile
logger.info(f"ArtemisClient running task '{goal}' with profile '{target_profile}'...")
target_device = device_serial or device_id or self.device_id
if target_device == "default-device":
target_device = device_pool.select_device() or "default-device"
state = await Pipeline.execute(
goal=goal,
profile=target_profile,
device_id=self.device_id,
mode = concurrency_mode or self.concurrency_mode
concurrency_limit = max_concurrency if max_concurrency is not None else self.max_concurrency
logger.info(
f"ArtemisClient running task '{goal}' on device '{target_device}' "
f"with profile '{target_profile}' (concurrency_mode='{mode}')..."
)
return TaskResult(
trace_id=state.trace_id,
status=state.status.value,
turns=state.current_turn,
error=state.error_message,
task_session_id = str(uuid.uuid4())
lock = DeviceExecutionLock(
device_id=target_device,
description=f"{goal[:120]}",
concurrency_mode=mode,
max_concurrency=concurrency_limit,
session_id=task_session_id,
ingress="sdk",
)
await asyncio.to_thread(
lock.acquire,
blocking=blocking,
timeout=lock_timeout,
)
try:
# Check if targeting a live connected device
devices = device_pool.list_devices()
connected_serials = {d.serial for d in devices}
is_live_device = target_device in connected_serials and os.environ.get("ARTEMIS_MOCK_DRIVER") != "1"
if is_live_device:
from artemis.sdk.agent import Agent
from artemis.sdk.builders import Builders
from artemis.context import DevicePlatform
cfg_builder = (
Builders.AgentConfig
.for_device(DevicePlatform.ANDROID, target_device)
.with_concurrency_mode(mode)
)
if concurrency_limit is not None:
cfg_builder.with_max_concurrency(concurrency_limit)
agent = Agent(config=cfg_builder.build())
agent._session_id = task_session_id
await agent.init()
try:
task = agent.new_task(goal=goal)
if target_profile:
task.using_profile(target_profile)
if kwargs.get("locked_package"):
task.with_locked_app_package(kwargs["locked_package"])
output = await agent.run_task(request=task.build())
return TaskResult(
trace_id=task_session_id,
status="completed" if output is not None else "success",
turns=getattr(agent, "_turns_completed", 1),
output=output,
device_id=target_device,
)
finally:
await agent.clean()
state = await Pipeline.execute(
goal=goal,
profile=target_profile,
device_id=target_device,
**kwargs,
)
return TaskResult(
trace_id=state.trace_id,
status=state.status.value,
turns=state.current_turn,
error=state.error_message,
device_id=target_device,
)
finally:
await asyncio.to_thread(lock.release)
async def stream_run(
self,
goal: str,
profile: Literal["flash", "pro"] | None = None,
device_id: str | None = None,
device_serial: str | None = None,
concurrency_mode: Literal["global", "per_device"] | str | None = None,
max_concurrency: int | None = None,
blocking: bool = True,
lock_timeout: float | None = None,
max_turns: int = 30,
callbacks: list[Callable[[StreamEvent], Any]] | None = None,
**kwargs: Any,
) -> AsyncGenerator[StreamEvent, None]:
"""Executes an autonomous task while yielding real-time StreamEvents asynchronously."""
target_profile = profile or self.default_profile
logger.info(f"ArtemisClient streaming task '{goal}' with profile '{target_profile}'...")
target_device = device_serial or device_id or self.device_id
if target_device == "default-device":
target_device = device_pool.select_device() or "default-device"
# 1. Emit initial starting event
start_event = StreamEvent(
event_type=StreamEventType.STATUS,
step_number=0,
payload={
"status": "starting",
"goal": goal,
"profile": target_profile,
"device_id": self.device_id,
},
mode = concurrency_mode or self.concurrency_mode
concurrency_limit = max_concurrency if max_concurrency is not None else self.max_concurrency
logger.info(
f"ArtemisClient streaming task '{goal}' on device '{target_device}' "
f"with profile '{target_profile}' (concurrency_mode='{mode}')..."
)
if callbacks:
for cb in callbacks:
try:
cb(start_event)
except Exception as e:
logger.debug(f"Callback error: {e}")
yield start_event
# 2. Execute task pipeline
lock = DeviceExecutionLock(
device_id=target_device,
description=f"ArtemisClient streaming task: {goal[:120]}",
concurrency_mode=mode,
max_concurrency=concurrency_limit,
)
await asyncio.to_thread(
lock.acquire,
blocking=blocking,
timeout=lock_timeout,
)
try:
state = await Pipeline.execute(
goal=goal,
profile=target_profile,
device_id=self.device_id,
)
# 3. Emit intermediate step events
for step in state.steps:
ev = StreamEvent(
event_type=StreamEventType.STEP_END,
step_number=step.step_number,
payload={
"thought": step.thought,
"action": step.action_name,
"params": step.action_params,
"result": step.result,
"duration": step.duration_seconds,
},
)
if callbacks:
for cb in callbacks:
try:
cb(ev)
except Exception as e:
logger.debug(f"Callback error: {e}")
yield ev
# 4. Emit completion event
final_ev = StreamEvent(
# 1. Emit initial starting event
start_event = StreamEvent(
event_type=StreamEventType.STATUS,
step_number=state.current_turn,
step_number=0,
payload={
"status": state.status.value,
"turns": state.current_turn,
"error": state.error_message,
"status": "starting",
"goal": goal,
"profile": target_profile,
"device_id": target_device,
"concurrency_mode": mode,
},
)
if callbacks:
for cb in callbacks:
try:
cb(final_ev)
cb(start_event)
except Exception as e:
logger.debug(f"Callback error: {e}")
yield final_ev
yield start_event
except Exception as e:
err_ev = StreamEvent(
event_type=StreamEventType.ERROR,
step_number=0,
payload={"error": str(e)},
)
if callbacks:
for cb in callbacks:
try:
cb(err_ev)
except Exception:
pass
yield err_ev
# 2. Execute task pipeline
try:
state = await Pipeline.execute(
goal=goal,
profile=target_profile,
device_id=target_device,
**kwargs,
)
# 3. Emit intermediate step events
for step in state.steps:
ev = StreamEvent(
event_type=StreamEventType.STEP_END,
step_number=step.step_number,
payload={
"thought": step.thought,
"action": step.action_name,
"params": step.action_params,
"result": step.result,
"duration": step.duration_seconds,
},
)
if callbacks:
for cb in callbacks:
try:
cb(ev)
except Exception as e:
logger.debug(f"Callback error: {e}")
yield ev
# 4. Emit completion event
final_ev = StreamEvent(
event_type=StreamEventType.STATUS,
step_number=state.current_turn,
payload={
"status": state.status.value,
"turns": state.current_turn,
"error": state.error_message,
"device_id": target_device,
},
)
if callbacks:
for cb in callbacks:
try:
cb(final_ev)
except Exception as e:
logger.debug(f"Callback error: {e}")
yield final_ev
except Exception as e:
err_ev = StreamEvent(
event_type=StreamEventType.ERROR,
step_number=0,
payload={"error": str(e), "device_id": target_device},
)
if callbacks:
for cb in callbacks:
try:
cb(err_ev)
except Exception:
pass
yield err_ev
finally:
await asyncio.to_thread(lock.release)
async def run_task(
self,
task: Task,
profile: Literal["flash", "pro"] | None = None,
device_id: str | None = None,
device_serial: str | None = None,
concurrency_mode: Literal["global", "per_device"] | str | None = None,
**kwargs: Any,
) -> TaskResult:
"""Executes a Task instance using ArtemisClient."""
target_device = (
device_serial
or device_id
or task.device_serial
or task.device_id
or self.device_id
)
target_profile = profile or task.profile or self.default_profile
target_mode = concurrency_mode or task.concurrency_mode or self.concurrency_mode
return await self.run(
goal=task.goal,
profile=target_profile,
device_id=target_device,
concurrency_mode=target_mode,
max_turns=task.max_turns,
locked_package=task.locked_package,
**kwargs,
)
+14
View File
@@ -44,9 +44,22 @@ class Task(BaseModel):
goal: str = Field(..., description="High-level goal description")
profile: Literal["flash", "pro"] = Field(default="pro", description="Execution profile")
device_id: str = Field(default="default-device", description="Target device serial")
device_serial: str | None = Field(
default=None, description="Target device serial (alias for device_id)"
)
concurrency_mode: Literal["global", "per_device"] | None = Field(
default=None,
description="Concurrency mode: 'global' (1 across all devices) or 'per_device' (1 per device)",
)
locked_package: str | None = Field(default=None, description="Package lock restriction")
max_turns: int = Field(default=30, description="Turn cutoff limit")
def model_post_init(self, __context: Any) -> None:
if self.device_serial and self.device_id == "default-device":
self.device_id = self.device_serial
elif self.device_id != "default-device" and not self.device_serial:
self.device_serial = self.device_id
class TaskResult(BaseModel):
"""Final structured outcome of a task execution."""
@@ -56,3 +69,4 @@ class TaskResult(BaseModel):
turns: int
output: Any = None
error: str | None = None
device_id: str | None = None
+5 -1
View File
@@ -204,6 +204,10 @@ class ModelFactory:
or os.environ.get("GOOGLE_API_KEY")
or os.environ.get("GEMINI_API_KEY")
)
thinking_level = endpoint.thinking_level
if endpoint.model_name and any(v in endpoint.model_name for v in ("2.5", "2.0", "1.5")):
thinking_level = None
kwargs: dict[str, Any] = {
"model": endpoint.model_name,
"temperature": endpoint.temperature,
@@ -211,7 +215,7 @@ class ModelFactory:
"api_key": api_key,
"timeout": endpoint.timeout_seconds,
"thinking_budget": endpoint.thinking_budget,
"thinking_level": endpoint.thinking_level,
"thinking_level": thinking_level,
"include_thoughts": endpoint.include_thoughts,
"safety_settings": {
HarmCategory.HARM_CATEGORY_HATE_SPEECH: HarmBlockThreshold.BLOCK_NONE,
+22 -15
View File
@@ -74,12 +74,16 @@ os.environ["ARTEMIS_IPC_PORT"] = ""
mcp = FastMCP("Android_ADB_Controller")
_GLOBAL_CONTROLLER = None
_CONTROLLERS: dict[str, Any] = {}
def _get_controller():
"""Lazy-load device controller on-demand as a singleton."""
global _GLOBAL_CONTROLLER
if _GLOBAL_CONTROLLER is not None:
def _get_controller(device_serial: str | None = None):
"""Lazy-load device controller on-demand, caching per device serial."""
global _GLOBAL_CONTROLLER, _CONTROLLERS
target_serial = device_serial or os.environ.get("ARTEMIS_DEVICE_ID") or os.environ.get("ADB_DEVICE_SERIAL")
if target_serial and target_serial in _CONTROLLERS:
return _CONTROLLERS[target_serial]
if not target_serial and _GLOBAL_CONTROLLER is not None:
return _GLOBAL_CONTROLLER
logger.info("Initializing lazy device controller...")
@@ -87,21 +91,21 @@ def _get_controller():
logger.info(
"GCP CLOUD MODE active: initializing CloudMobileDeviceController via UnifiedMobileController..."
)
target_serial = (
os.environ.get("ARTEMIS_DEVICE_ID")
or os.environ.get("ADB_DEVICE_SERIAL")
or "cloud_device"
)
resolved_serial = target_serial or "cloud_device"
ctx = ArtemisContext(
device=DeviceContext(
platform=DevicePlatform.ANDROID,
device_id=target_serial,
device_id=resolved_serial,
width=1080,
height=2400,
)
)
_GLOBAL_CONTROLLER = UnifiedMobileController(ctx=ctx)
return _GLOBAL_CONTROLLER
controller = UnifiedMobileController(ctx=ctx)
if resolved_serial:
_CONTROLLERS[resolved_serial] = controller
if _GLOBAL_CONTROLLER is None:
_GLOBAL_CONTROLLER = controller
return controller
host = os.environ.get("ADB_HOST", "localhost")
port_str = os.environ.get("ADB_PORT", "5037")
@@ -110,7 +114,6 @@ def _get_controller():
os.environ["ADB_SERVER_SOCKET"] = f"tcp:{host}:{port}"
adb = AdbClient(host=host, port=port)
target_serial = os.environ.get("ARTEMIS_DEVICE_ID") or os.environ.get("ADB_DEVICE_SERIAL")
devices = adb.device_list()
if not devices:
raise Exception(f"No Android devices found at {host}:{port}")
@@ -148,9 +151,13 @@ def _get_controller():
ui_adb_client=ui_client,
)
_GLOBAL_CONTROLLER = UnifiedMobileController(ctx)
controller = UnifiedMobileController(ctx)
if device_id:
_CONTROLLERS[device_id] = controller
if _GLOBAL_CONTROLLER is None:
_GLOBAL_CONTROLLER = controller
logger.info(f"Lazy device controller fully initialized for device: {device_id}")
return _GLOBAL_CONTROLLER
return controller
def _find_element_at_coords(elements: list[dict], x: int, y: int) -> dict | None:
+36 -1
View File
@@ -14,22 +14,57 @@
"""Artemis cross-platform runtime and process supervisor subsystem."""
from artemis.runtime.device_lock import DeviceBusyError, DeviceExecutionLock
from artemis.runtime.device_lock import (
ConcurrencyMode,
DeviceBusyError,
DeviceExecutionLock,
)
from artemis.runtime.device_pool import DevicePool, DeviceStatus, device_pool
from artemis.runtime.daemon_client import (
ensure_daemon_running,
is_daemon_running,
submit_task_to_daemon,
)
from artemis.runtime.awake_service import (
ensure_device_awake,
sanitize_device_state,
screen_awake_service,
shutdown_awake_service,
start_awake_service,
)
from artemis.runtime.server_lifecycle import (
clear_server_info,
find_server_pids,
get_server_status,
is_port_in_use,
read_server_info,
stop_server,
write_server_info,
)
from artemis.runtime.supervisor import ProcessSupervisor, process_supervisor
__all__ = [
"ConcurrencyMode",
"DeviceBusyError",
"DeviceExecutionLock",
"DevicePool",
"DeviceStatus",
"clear_server_info",
"device_pool",
"ensure_daemon_running",
"ensure_device_awake",
"find_server_pids",
"get_server_status",
"is_daemon_running",
"is_port_in_use",
"ProcessSupervisor",
"process_supervisor",
"read_server_info",
"sanitize_device_state",
"screen_awake_service",
"shutdown_awake_service",
"start_awake_service",
"stop_server",
"submit_task_to_daemon",
"write_server_info",
]
+19 -1
View File
@@ -64,6 +64,24 @@ def _run_awake_adb_command(
return None
def sanitize_device_state(device_id: str) -> None:
"""Sanitize Android device state by cleaning up orphan screenrecord or modal dialogs."""
if not device_id:
return
# 1. Kill any dangling screenrecord processes left from interrupted tasks
_run_awake_adb_command(
device_id,
["shell", "pkill", "-f", "screenrecord"],
"kill orphan screenrecord processes",
)
# 2. Close system modal dialogs (ANR / crash popups)
_run_awake_adb_command(
device_id,
["shell", "am", "broadcast", "-a", "android.intent.action.CLOSE_SYSTEM_DIALOGS"],
"dismiss system crash/ANR dialogs",
)
def _configure_usb_stay_awake(device_id: str) -> str | None:
"""Enable USB stay-awake, falling back to an effective host heartbeat.
@@ -131,7 +149,7 @@ def _configure_usb_stay_awake(device_id: str) -> str | None:
def _discover_connected_device_ids() -> list[str]:
"""Return the selected or first connected local Android device."""
"""Return the selected or all connected local Android devices."""
if not _awake_enabled() or os.environ.get("ARTEMIS_CLOUD_MODE") == "1":
return []
+193
View File
@@ -0,0 +1,193 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Client utilities for communicating with and auto-spawning the Artemis Daemon."""
from __future__ import annotations
import json
import os
from pathlib import Path
import subprocess
import sys
import time
from typing import Any
import urllib.error
import urllib.request
from artemis.config.paths import ROOT_DIR
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
DEFAULT_DAEMON_HOST = os.environ.get("ARTEMIS_DAEMON_HOST", "127.0.0.1")
DEFAULT_DAEMON_PORT = int(os.environ.get("ARTEMIS_DAEMON_PORT", "8000"))
def is_standalone_forced() -> bool:
"""Return True if standalone/embedded execution is explicitly configured."""
return os.environ.get("ARTEMIS_STANDALONE", "").lower() in ("1", "true", "yes")
def is_daemon_running(
host: str = DEFAULT_DAEMON_HOST,
port: int = DEFAULT_DAEMON_PORT,
timeout: float = 0.3,
) -> bool:
"""Fast probe to check if the Artemis Daemon HTTP server is responding."""
url = f"http://{host}:{port}/api/system/readiness"
try:
req = urllib.request.Request(url, headers={"User-Agent": "Artemis-Daemon-Probe"})
with urllib.request.urlopen(req, timeout=timeout) as response:
return response.status in (200, 400, 404, 503)
except Exception:
# Fallback probe to root endpoint
try:
root_url = f"http://{host}:{port}/"
req = urllib.request.Request(root_url, headers={"User-Agent": "Artemis-Daemon-Probe"})
with urllib.request.urlopen(req, timeout=timeout) as response:
return response.status in (200, 301, 302, 404)
except Exception:
return False
def spawn_daemon(
host: str = DEFAULT_DAEMON_HOST,
port: int = DEFAULT_DAEMON_PORT,
) -> subprocess.Popen | None:
"""Silently spawn the Artemis Daemon server in the background as a detached process."""
env = os.environ.copy()
env["PYTHONUNBUFFERED"] = "1"
env["PYTHONUTF8"] = "1"
cmd = [
sys.executable,
"-m",
"apps.admin_console.server",
"--host",
host,
"--port",
str(port),
]
kwargs: dict[str, Any] = {
"cwd": str(ROOT_DIR),
"env": env,
"stdout": subprocess.DEVNULL,
"stderr": subprocess.DEVNULL,
"stdin": subprocess.DEVNULL,
}
if sys.platform == "win32":
kwargs["creationflags"] = (
subprocess.CREATE_NEW_PROCESS_GROUP
| getattr(subprocess, "DETACHED_PROCESS", 0x00000008)
| getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000)
)
else:
kwargs["start_new_session"] = True
try:
proc = subprocess.Popen(cmd, **kwargs)
logger.info(f"Spawned Artemis Daemon in background (PID {proc.pid}) at http://{host}:{port}")
return proc
except Exception as exc:
logger.warning(f"Could not auto-spawn Artemis Daemon: {exc}")
return None
def ensure_daemon_running(
host: str = DEFAULT_DAEMON_HOST,
port: int = DEFAULT_DAEMON_PORT,
timeout: float = 2.0,
wait_ready: bool = False,
) -> tuple[bool, str | None]:
"""Ensure the Artemis Daemon is running, auto-spawning it if necessary.
Args:
host: Daemon host to probe.
port: Daemon port to probe.
timeout: Maximum seconds to wait if wait_ready is True.
wait_ready: If True, blocks until Daemon is healthy or timeout reached.
If False, spawns asynchronously and returns immediately.
Returns:
(True, base_url) if Daemon is accessible.
(False, None) if standalone mode is forced or Daemon could not be reached.
"""
if is_standalone_forced():
return False, None
if is_daemon_running(host, port):
return True, f"http://{host}:{port}"
# Auto-spawn Daemon in background
spawn_daemon(host, port)
if not wait_ready:
return True, f"http://{host}:{port}"
# Poll until ready or timeout
started = time.monotonic()
while time.monotonic() - started < timeout:
time.sleep(0.1)
if is_daemon_running(host, port):
return True, f"http://{host}:{port}"
logger.warning(f"Daemon did not respond within {timeout}s. Falling back to standalone mode.")
return False, None
def submit_task_to_daemon(
goal: str,
*,
profile: str = "flash",
device_serial: str | None = None,
expected_output: str | None = None,
enable_outputter: bool | None = None,
locked_app_package: str | None = None,
app_path: str | None = None,
base_url: str | None = None,
timeout: float = 5.0,
) -> dict[str, Any] | None:
"""Submit a task to the running Daemon via REST API.
Returns the response JSON dict if successfully enqueued, or None on error.
"""
url = f"{base_url or f'http://{DEFAULT_DAEMON_HOST}:{DEFAULT_DAEMON_PORT}'}/api/run"
payload = {
"goal": goal,
"profile": profile,
"device_serial": device_serial,
"expected_output": expected_output,
"enable_outputter": enable_outputter,
"locked_app_package": locked_app_package,
"app_path": app_path,
}
try:
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
url,
data=data,
headers={"Content-Type": "application/json", "User-Agent": "Artemis-Client"},
method="POST",
)
with urllib.request.urlopen(req, timeout=timeout) as response:
if response.status in (200, 201):
return json.loads(response.read().decode("utf-8"))
except Exception as exc:
logger.debug(f"Failed to submit task to Daemon at {url}: {exc}")
return None
return None
+396 -92
View File
@@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
"""Cross-process FIFO execution queue for the local Artemis device."""
"""Cross-process FIFO execution queue and per-device mutex for Artemis devices."""
from __future__ import annotations
@@ -21,6 +21,7 @@ from datetime import UTC, datetime
import json
import os
from pathlib import Path
import re
import time
import uuid
@@ -31,6 +32,13 @@ class DeviceBusyError(RuntimeError):
"""Raised when another Artemis process already owns a device."""
class ConcurrencyMode:
"""Supported concurrency strategies for Artemis task execution."""
GLOBAL = "global" # Strict serial mode: 1 task globally across all devices
PER_DEVICE = "per_device" # 1 task per device: concurrent across different devices
@dataclass(frozen=True)
class DeviceLockOwner:
pid: int
@@ -61,45 +69,73 @@ class DeviceLockOwner:
class DeviceExecutionLock:
"""A PID-aware FIFO lease shared by UI, CLI, SDK, and MCP tasks.
"""A PID-aware FIFO lease supporting per-device mutexes and global concurrency limits.
Artemis intentionally supports one active local device. A single global
queue therefore orders every local task regardless of which API or process
submitted it. Submission layers may reserve a queue ticket before spawning
a worker; the worker receives that token through the environment and claims
the same position. Tasks without a submission layer reserve at acquire time.
Artemis enforces a single active task per mobile device at any time.
Different devices can execute tasks concurrently unless a global concurrency
limit (e.g. concurrency_mode="global" or ARTEMIS_MAX_CONCURRENT_TASKS=1) is configured.
Atomic file creation provides exclusion. PID creation time prevents stale
owners or queue tickets from blocking execution after a process exits or
after the operating system reuses the same PID.
Atomic file creation provides exclusion per device serial. PID creation time
prevents stale owners or queue tickets from blocking execution after a process
exits or after the operating system reuses the same PID.
"""
_MALFORMED_LOCK_GRACE_SECONDS = 5.0
_DEFAULT_POLL_INTERVAL_SECONDS = 0.1
QUEUE_TICKET_ENV = "ARTEMIS_DEVICE_QUEUE_TICKET"
@staticmethod
def _normalize_device_id(device_id: str | None) -> str:
"""Normalize device serial/identifier to a filesystem-safe string."""
if not device_id or str(device_id).strip() in ("", "default-device", "default"):
return "default"
raw = str(device_id).strip()
if raw.lower() in ("pending", "any"):
return raw.lower()
clean = re.sub(r"[^a-zA-Z0-9_-]", "_", raw)
return clean or "default"
def __init__(
self,
device_id: str,
device_id: str = "default",
description: str = "Artemis task",
queue_ticket: str | None = None,
max_concurrency: int | None = None,
concurrency_mode: str | None = None,
session_id: str | None = None,
ingress: str | None = None,
):
self.device_id = device_id
self.device_id = device_id or "default"
self.clean_device_id = self._normalize_device_id(self.device_id)
self.description = description
self.max_concurrency = max_concurrency
self.concurrency_mode = (
str(concurrency_mode).strip().lower() if concurrency_mode is not None else None
)
self.token = uuid.uuid4().hex
lock_dir = get_temp_dir("device-locks")
self.path = lock_dir / "artemis-global-device.lock"
# Per-device lock file
self.path = lock_dir / f"artemis-device-{self.clean_device_id}.lock"
# Shared FIFO wait queue directory across all processes
self.queue_dir = lock_dir / "artemis-global-device.queue"
self.queue_ticket = queue_ticket or os.environ.pop(self.QUEUE_TICKET_ENV, None)
self.session_id = os.getenv("ARTEMIS_SESSION_ID") or os.getenv(
self._initial_queue_ticket = queue_ticket or os.environ.pop(self.QUEUE_TICKET_ENV, None)
self.queue_ticket = self._initial_queue_ticket
self.session_id = session_id or os.getenv("ARTEMIS_SESSION_ID") or os.getenv(
"ARTEMIS_CLOUD_SESSION_ID"
)
self.ingress = os.getenv("ARTEMIS_TASK_INGRESS") or "sdk"
self.ingress = ingress or os.getenv("ARTEMIS_TASK_INGRESS") or "sdk"
self._queue_path: Path | None = None
self._acquired = False
@classmethod
def _device_lock_path(cls, device_id: str | None) -> Path:
clean_id = cls._normalize_device_id(device_id)
return get_temp_dir("device-locks") / f"artemis-device-{clean_id}.lock"
@classmethod
def _global_lock_path(cls) -> Path:
# Kept for backward compatibility and fallback detection
return get_temp_dir("device-locks") / "artemis-global-device.lock"
@staticmethod
@@ -111,8 +147,34 @@ class DeviceExecutionLock:
except Exception:
return 0.0
@staticmethod
def _safe_unlink(path: Path, max_retries: int = 3, delay: float = 0.02) -> bool:
"""Safely unlink a file with retries on Windows PermissionError / sharing violations."""
for i in range(max_retries):
try:
path.unlink(missing_ok=True)
return True
except OSError:
if i < max_retries - 1:
time.sleep(delay)
return False
@staticmethod
def _safe_replace(src: Path, dst: Path, max_retries: int = 3, delay: float = 0.02) -> bool:
"""Safely replace a file with retries on Windows PermissionError / sharing violations."""
for i in range(max_retries):
try:
os.replace(src, dst)
return True
except OSError:
if i < max_retries - 1:
time.sleep(delay)
return False
@staticmethod
def _owner_is_alive(owner: DeviceLockOwner) -> bool:
if owner.pid <= 0:
return False
try:
import psutil
@@ -120,7 +182,12 @@ class DeviceExecutionLock:
if not process.is_running():
return False
if owner.process_created_at <= 0:
return True
try:
name = process.name().lower()
cmdline = " ".join(process.cmdline()).lower()
return any(k in name or k in cmdline for k in ("python", "artemis", "pytest"))
except Exception:
return False
return abs(process.create_time() - owner.process_created_at) < 1.0
except Exception:
return False
@@ -145,11 +212,7 @@ class DeviceExecutionLock:
except OSError:
return True
try:
self.path.unlink(missing_ok=True)
return True
except OSError:
return False
return self._safe_unlink(self.path)
@classmethod
def _owner_payload(
@@ -184,7 +247,7 @@ class DeviceExecutionLock:
session_id: str | None = None,
ingress: str | None = None,
) -> str:
"""Reserve a global FIFO position before a worker process is spawned."""
"""Reserve a FIFO position in the global queue before a worker process is spawned."""
token = uuid.uuid4().hex
queue_dir = get_temp_dir("device-locks") / "artemis-global-device.queue"
queue_dir.mkdir(parents=True, exist_ok=True)
@@ -216,12 +279,13 @@ class DeviceExecutionLock:
return False
queue_dir = get_temp_dir("device-locks") / "artemis-global-device.queue"
removed = False
for path in queue_dir.glob(f"*-{token}.wait"):
try:
path.unlink(missing_ok=True)
removed = True
except OSError:
pass
if queue_dir.exists():
for path in queue_dir.glob(f"*-{token}.wait"):
try:
path.unlink(missing_ok=True)
removed = True
except OSError:
pass
return removed
@classmethod
@@ -235,13 +299,10 @@ class DeviceExecutionLock:
session_id: str | None = None,
ingress: str | None = None,
) -> bool:
"""Transfer a submission ticket to its worker process.
This makes a ticket stale as soon as a worker that failed before Agent
initialization exits, instead of keeping it alive with the submitting
server process.
"""
"""Transfer a submission ticket to its worker process."""
queue_dir = get_temp_dir("device-locks") / "artemis-global-device.queue"
if not queue_dir.exists():
return False
matches = list(queue_dir.glob(f"*-{token}.wait"))
path = min(matches, default=None)
if path is None:
@@ -294,7 +355,12 @@ class DeviceExecutionLock:
path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
return path
self.queue_ticket = self.reserve(self.description, self.device_id)
self.queue_ticket = self.reserve(
self.description,
self.device_id,
session_id=self.session_id,
ingress=self.ingress,
)
path = self._find_reserved_ticket()
if path is None:
raise DeviceBusyError("The Artemis device queue ticket could not be created.")
@@ -334,6 +400,17 @@ class DeviceExecutionLock:
try:
fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
except FileExistsError:
current_owner = self._read_owner(self.path)
if (
current_owner is not None
and current_owner.pid == os.getpid()
and (
current_owner.token == self.token
or (self.session_id and current_owner.session_id == self.session_id)
)
):
self._acquired = True
return True
self._remove_stale_lock()
return False
try:
@@ -343,6 +420,31 @@ class DeviceExecutionLock:
self._acquired = True
return True
@property
def effective_concurrency(self) -> int:
"""Resolve effective concurrency limit:
0: per-device concurrency (multiple devices run concurrently, FIFO per device)
1: global concurrency (strict single-task serialization across all devices)
>1: global concurrency capped at N tasks across all devices
"""
if self.concurrency_mode is not None:
mode = self.concurrency_mode
if mode in ("global", "serial", "1"):
return 1
if mode in ("per_device", "device", "parallel", "0"):
return 0
if self.max_concurrency is not None:
return self.max_concurrency
env_mode = os.environ.get("ARTEMIS_CONCURRENCY_MODE", "").strip().lower()
if env_mode in ("global", "serial", "1"):
return 1
if env_mode in ("per_device", "device", "parallel", "0"):
return 0
try:
return int(os.environ.get("ARTEMIS_MAX_CONCURRENT_TASKS", 0))
except (ValueError, TypeError):
return 0
def acquire(
self,
*,
@@ -351,20 +453,71 @@ class DeviceExecutionLock:
poll_interval: float = _DEFAULT_POLL_INTERVAL_SECONDS,
cancel_event=None,
) -> None:
"""Wait in FIFO order and acquire exclusive access to the Artemis device."""
"""Wait in FIFO order and acquire exclusive access to the target Artemis device."""
self._queue_path = self._claim_or_create_queue_ticket()
started_at = time.monotonic()
effective_concurrency = self.effective_concurrency
try:
while True:
if cancel_event is not None and cancel_event.is_set():
raise DeviceBusyError("Waiting for the Artemis device queue was cancelled.")
self._remove_stale_queue_entries()
queue = sorted(self.queue_dir.glob("*.wait"))
if not self._queue_path.exists():
raise DeviceBusyError("The Artemis queue reservation was cancelled.")
if queue and queue[0] == self._queue_path and self._try_acquire_owner_lock():
self._queue_path.unlink(missing_ok=True)
# 1. Check global concurrency limit if configured (> 0)
if effective_concurrency > 0:
active_owners = self.get_active_owners()
active_other_owners = [
o
for o in active_owners.values()
if not (
(o.pid == os.getpid() and ((self.session_id and o.session_id == self.session_id) or o.token == self.token))
)
]
if len(active_other_owners) >= effective_concurrency:
if not blocking:
owner_desc = (
active_other_owners[0].description
if active_other_owners
else "active task"
)
raise DeviceBusyError(
f"Global task concurrency limit ({effective_concurrency}) reached ({owner_desc})."
)
if timeout is not None and time.monotonic() - started_at >= timeout:
raise DeviceBusyError(
f"Timed out waiting for a concurrency slot after {timeout:.1f}s."
)
time.sleep(max(0.01, poll_interval))
continue
# 2. Determine FIFO eligibility for this specific device
all_wait_files = sorted(self.queue_dir.glob("*.wait"))
if effective_concurrency == 1:
# In strict serial mode, global FIFO applies; re-entrant parent processes are always eligible
active_owners = self.get_active_owners()
is_reentrant = any(
o.pid == os.getpid() and ((self.session_id and o.session_id == self.session_id) or o.token == self.token)
for o in active_owners.values()
)
is_eligible = is_reentrant or bool(all_wait_files and all_wait_files[0] == self._queue_path)
else:
# In multi-device parallel mode, per-device FIFO applies
target_dev = self.clean_device_id
device_queue: list[Path] = []
for wait_path in all_wait_files:
ticket_owner = self._read_owner(wait_path)
if ticket_owner is None:
continue
ticket_dev = self._normalize_device_id(ticket_owner.device_id)
if ticket_dev == target_dev or ticket_dev in ("pending", "any"):
device_queue.append(wait_path)
is_eligible = bool(device_queue and device_queue[0] == self._queue_path)
if is_eligible and self._try_acquire_owner_lock():
self._safe_unlink(self._queue_path)
self._queue_path = None
return
@@ -385,8 +538,10 @@ class DeviceExecutionLock:
time.sleep(max(0.01, poll_interval))
except BaseException:
if self._queue_path is not None:
self._queue_path.unlink(missing_ok=True)
self._safe_unlink(self._queue_path)
self._queue_path = None
if not self._initial_queue_ticket:
self.queue_ticket = None
raise
def release(self) -> None:
@@ -394,15 +549,16 @@ class DeviceExecutionLock:
return
try:
owner = self._read_owner(self.path)
if owner is not None and owner.token == self.token:
self.path.unlink(missing_ok=True)
if owner is not None and (
owner.token == self.token
or (self.session_id and owner.session_id == self.session_id)
):
self._safe_unlink(self.path)
finally:
self._acquired = False
@classmethod
def get_active_owner(cls) -> DeviceLockOwner | None:
"""Return the live process that currently owns the global device lease."""
path = cls._global_lock_path()
def _get_active_owner_for_path(cls, path: Path) -> DeviceLockOwner | None:
owner = None
for attempt in range(3):
owner = cls._read_owner(path)
@@ -413,27 +569,135 @@ class DeviceExecutionLock:
if owner is None:
try:
if path.exists() and time.time() - path.stat().st_mtime >= cls._MALFORMED_LOCK_GRACE_SECONDS:
path.unlink(missing_ok=True)
cls._safe_unlink(path)
except OSError:
pass
return None
if cls._owner_is_alive(owner):
return owner
try:
path.unlink(missing_ok=True)
except OSError:
pass
cls._safe_unlink(path)
return None
@classmethod
def has_owner_record(cls) -> bool:
"""Return whether an owner record exists, including a record being written."""
return cls._global_lock_path().exists()
def get_active_owners(cls) -> dict[str, DeviceLockOwner]:
"""Return a mapping of clean_device_id -> live DeviceLockOwner across all devices."""
lock_dir = get_temp_dir("device-locks")
if not lock_dir.exists():
return {}
active_map: dict[str, DeviceLockOwner] = {}
seen_tokens = set()
for lock_path in sorted(lock_dir.glob("artemis-device-*.lock")):
owner = cls._get_active_owner_for_path(lock_path)
if owner is not None and owner.token not in seen_tokens:
dev_key = cls._normalize_device_id(owner.device_id)
active_map[dev_key] = owner
seen_tokens.add(owner.token)
# Also check legacy global lock if still present
global_lock = lock_dir / "artemis-global-device.lock"
if global_lock.exists():
owner = cls._get_active_owner_for_path(global_lock)
if owner is not None and owner.token not in seen_tokens:
dev_key = cls._normalize_device_id(owner.device_id)
active_map[dev_key] = owner
seen_tokens.add(owner.token)
return active_map
@classmethod
def is_active_owner(cls, expected: DeviceLockOwner) -> bool:
def get_active_owner(cls, device_id: str | None = None) -> DeviceLockOwner | None:
"""Return the live process owning the lock for a specific device, or any device if None."""
lock_dir = get_temp_dir("device-locks")
if device_id is not None:
clean_id = cls._normalize_device_id(device_id)
dev_path = lock_dir / f"artemis-device-{clean_id}.lock"
owner = cls._get_active_owner_for_path(dev_path)
if owner is not None:
return owner
# Check legacy global lock as fallback
global_path = lock_dir / "artemis-global-device.lock"
if global_path.exists():
return cls._get_active_owner_for_path(global_path)
return None
# Return any active owner for single-device caller backward compatibility
active_owners = cls.get_active_owners()
for owner in active_owners.values():
return owner
return None
@classmethod
def get_queued_tasks(cls, device_id: str | None = None) -> list[dict[str, Any]]:
"""Return all live pending queue tickets across all devices in FIFO order."""
lock_dir = get_temp_dir("device-locks")
queue_dir = lock_dir / "artemis-global-device.queue"
if not queue_dir.exists():
return []
clean_target = cls._normalize_device_id(device_id) if device_id else None
active_tokens = {o.token for o in cls.get_active_owners().values()}
queued: list[dict[str, Any]] = []
for path in sorted(queue_dir.glob("*.wait")):
owner = cls._read_owner(path)
if owner is None:
continue
# If the owner process died, ignore
if not cls._owner_is_alive(owner):
continue
# If this token already acquired the lock, it is actively running, not pending
if owner.token in active_tokens:
continue
target_dev = cls._normalize_device_id(owner.device_id)
if clean_target is not None and target_dev not in (clean_target, "pending", "any"):
continue
try:
created_at = path.stat().st_mtime
except OSError:
created_at = time.time()
queued.append({
"session_id": owner.session_id or f"queued-{owner.token[:8]}",
"goal": owner.description,
"device_id": owner.device_id,
"device_serial": owner.device_id,
"pid": owner.pid,
"token": owner.token,
"ingress": owner.ingress or "unknown",
"status": "pending",
"created_at": created_at,
"start_time": created_at,
})
return queued
@classmethod
def has_owner_record(cls, device_id: str | None = None) -> bool:
"""Return whether an owner record exists, including a record being written."""
lock_dir = get_temp_dir("device-locks")
if not lock_dir.exists():
return False
if device_id is not None:
clean_id = cls._normalize_device_id(device_id)
return (
lock_dir / f"artemis-device-{clean_id}.lock"
).exists() or (lock_dir / "artemis-global-device.lock").exists()
return bool(list(lock_dir.glob("artemis-device-*.lock"))) or (
lock_dir / "artemis-global-device.lock"
).exists()
@classmethod
def is_active_owner(cls, expected: DeviceLockOwner, device_id: str | None = None) -> bool:
"""Revalidate a previously read owner before performing a destructive action."""
current = cls._read_owner(cls._global_lock_path())
target_dev = device_id or expected.device_id
lock_dir = get_temp_dir("device-locks")
clean_id = cls._normalize_device_id(target_dev)
path = lock_dir / f"artemis-device-{clean_id}.lock"
if not path.exists():
path = lock_dir / "artemis-global-device.lock"
current = cls._read_owner(path)
return bool(
current
and current.token == expected.token
@@ -442,15 +706,42 @@ class DeviceExecutionLock:
and cls._owner_is_alive(current)
)
@classmethod
def _find_lock_by_pid(cls, pid: int) -> Path | None:
lock_dir = get_temp_dir("device-locks")
if not lock_dir.exists():
return None
for path in sorted(lock_dir.glob("artemis-device-*.lock")):
owner = cls._read_owner(path)
if owner is not None and owner.pid == pid:
return path
global_path = lock_dir / "artemis-global-device.lock"
if global_path.exists():
owner = cls._read_owner(global_path)
if owner is not None and owner.pid == pid:
return global_path
return None
@classmethod
def annotate_active_owner(
cls,
*,
session_id: str,
ingress: str | None = None,
device_id: str | None = None,
) -> bool:
"""Attach session metadata after tracing starts inside the lease owner."""
path = cls._global_lock_path()
lock_dir = get_temp_dir("device-locks")
if device_id is not None:
clean_id = cls._normalize_device_id(device_id)
path = lock_dir / f"artemis-device-{clean_id}.lock"
if not path.exists():
path = lock_dir / "artemis-global-device.lock"
else:
path = cls._find_lock_by_pid(os.getpid())
if path is None:
path = lock_dir / "artemis-global-device.lock"
owner = cls._read_owner(path)
if owner is None or owner.pid != os.getpid() or not cls._owner_is_alive(owner):
return False
@@ -467,50 +758,63 @@ class DeviceExecutionLock:
temp_path = path.with_name(f"{path.name}.{uuid.uuid4().hex}.tmp")
try:
temp_path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
os.replace(temp_path, path)
if not cls._safe_replace(temp_path, path):
return False
except OSError:
return False
finally:
try:
temp_path.unlink(missing_ok=True)
except OSError:
pass
cls._safe_unlink(temp_path)
return True
@classmethod
def cleanup_stale_locks(cls) -> int:
def cleanup_stale_locks(cls, device_id: str | None = None) -> int:
"""Remove execution leases and queue tickets whose owners are gone."""
removed = 0
lock_dir = get_temp_dir("device-locks")
for path in lock_dir.glob("*.lock"):
owner = cls._read_owner(path)
if owner is not None and cls._owner_is_alive(owner):
continue
if owner is None:
try:
if time.time() - path.stat().st_mtime < cls._MALFORMED_LOCK_GRACE_SECONDS:
continue
except OSError:
if not lock_dir.exists():
return removed
patterns = (
[f"artemis-device-{cls._normalize_device_id(device_id)}.lock"]
if device_id
else ["artemis-device-*.lock", "artemis-global-device.lock", "*.lock"]
)
seen_paths: set[Path] = set()
for pat in patterns:
for path in lock_dir.glob(pat):
if path in seen_paths:
continue
try:
path.unlink(missing_ok=True)
removed += 1
except OSError:
pass
queue_dir = lock_dir / "artemis-global-device.queue"
for path in queue_dir.glob("*.wait"):
owner = cls._read_owner(path)
if owner is not None and cls._owner_is_alive(owner):
continue
if owner is None:
try:
if time.time() - path.stat().st_mtime < cls._MALFORMED_LOCK_GRACE_SECONDS:
continue
except OSError:
seen_paths.add(path)
owner = cls._read_owner(path)
if owner is not None and cls._owner_is_alive(owner):
continue
try:
path.unlink(missing_ok=True)
removed += 1
except OSError:
pass
if owner is None:
try:
if (
time.time() - path.stat().st_mtime
< cls._MALFORMED_LOCK_GRACE_SECONDS
):
continue
except OSError:
continue
if cls._safe_unlink(path):
removed += 1
for q_dir in lock_dir.glob("*.queue"):
if q_dir.is_dir():
for path in q_dir.glob("*.wait"):
owner = cls._read_owner(path)
if owner is not None and cls._owner_is_alive(owner):
continue
if owner is None:
try:
if (
time.time() - path.stat().st_mtime
< cls._MALFORMED_LOCK_GRACE_SECONDS
):
continue
except OSError:
continue
if cls._safe_unlink(path):
removed += 1
return removed
+243
View File
@@ -0,0 +1,243 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Device Pool Manager for discovering, tracking, and allocating mobile devices."""
from __future__ import annotations
import asyncio
from dataclasses import dataclass
import shutil
import subprocess
from typing import Any
from artemis.runtime.device_lock import DeviceExecutionLock
from artemis.toolchain import toolchain
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
@dataclass
class DeviceStatus:
"""State and lock allocation metadata for a connected device."""
serial: str
state: str # "device", "offline", "unauthorized", etc.
model: str | None = None
product: str | None = None
is_emulator: bool = False
is_busy: bool = False
active_pid: int | None = None
active_task_desc: str | None = None
active_session_id: str | None = None
acquired_at: str | None = None
def to_dict(self) -> dict[str, Any]:
return {
"serial": self.serial,
"state": self.state,
"model": self.model,
"product": self.product,
"is_emulator": self.is_emulator,
"is_busy": self.is_busy,
"active_pid": self.active_pid,
"active_task_desc": self.active_task_desc,
"active_session_id": self.active_session_id,
"acquired_at": self.acquired_at,
}
class DevicePool:
"""Manages discovery and assignment across all connected Android devices."""
def __init__(self, adb_path: str | None = None):
self._adb_path = adb_path
def _resolve_adb(self) -> str | None:
if self._adb_path:
return self._adb_path
try:
return toolchain.resolve("adb") or shutil.which("adb")
except Exception:
return shutil.which("adb")
def _query_adb_devices_sync(self, timeout: float = 2.0) -> list[tuple[str, str, str | None, str | None]]:
"""Run `adb devices -l` synchronously to parse attached devices."""
adb = self._resolve_adb()
if not adb:
return []
try:
res = subprocess.run(
[adb, "devices", "-l"],
capture_output=True,
text=True,
timeout=timeout,
check=False,
)
if res.returncode != 0:
return []
return self._parse_device_lines(res.stdout.splitlines())
except Exception as exc:
logger.debug(f"Error querying adb devices: {exc}")
return []
async def _query_adb_devices_async(self, timeout: float = 2.0) -> list[tuple[str, str, str | None, str | None]]:
"""Run `adb devices -l` asynchronously to parse attached devices."""
adb = self._resolve_adb()
if not adb:
return []
proc = None
try:
proc = await asyncio.create_subprocess_exec(
adb,
"devices",
"-l",
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
if proc.returncode != 0:
return []
return self._parse_device_lines(stdout.decode(errors="replace").splitlines())
except Exception as exc:
logger.debug(f"Error querying adb devices asynchronously: {exc}")
if proc is not None:
try:
proc.kill()
except Exception:
pass
return []
@staticmethod
def _parse_device_lines(lines: list[str]) -> list[tuple[str, str, str | None, str | None]]:
results: list[tuple[str, str, str | None, str | None]] = []
for raw_line in lines:
line = raw_line.strip()
if not line or line.startswith("List of devices"):
continue
parts = line.split()
if len(parts) < 2:
continue
serial = parts[0]
state = parts[1]
model = None
product = None
for token in parts[2:]:
if token.startswith("model:"):
model = token.split(":", 1)[1].replace("_", " ")
elif token.startswith("product:"):
product = token.split(":", 1)[1]
results.append((serial, state, model, product))
return results
def list_devices(self) -> list[DeviceStatus]:
"""Synchronously list all connected devices along with their active lock state."""
raw_devices = self._query_adb_devices_sync()
active_owners = DeviceExecutionLock.get_active_owners()
devices: list[DeviceStatus] = []
for serial, state, model, product in raw_devices:
is_emu = serial.startswith("emulator-") or "127.0.0.1" in serial or "localhost" in serial
clean_id = DeviceExecutionLock._normalize_device_id(serial)
owner = active_owners.get(clean_id)
status = DeviceStatus(
serial=serial,
state=state,
model=model,
product=product,
is_emulator=is_emu,
is_busy=owner is not None,
active_pid=owner.pid if owner else None,
active_task_desc=owner.description if owner else None,
active_session_id=owner.session_id if owner else None,
acquired_at=owner.acquired_at if owner else None,
)
devices.append(status)
return devices
async def list_devices_async(self) -> list[DeviceStatus]:
"""Asynchronously list all connected devices along with their active lock state."""
raw_devices = await self._query_adb_devices_async()
active_owners = DeviceExecutionLock.get_active_owners()
devices: list[DeviceStatus] = []
for serial, state, model, product in raw_devices:
is_emu = serial.startswith("emulator-") or "127.0.0.1" in serial or "localhost" in serial
clean_id = DeviceExecutionLock._normalize_device_id(serial)
owner = active_owners.get(clean_id)
status = DeviceStatus(
serial=serial,
state=state,
model=model,
product=product,
is_emulator=is_emu,
is_busy=owner is not None,
active_pid=owner.pid if owner else None,
active_task_desc=owner.description if owner else None,
active_session_id=owner.session_id if owner else None,
acquired_at=owner.acquired_at if owner else None,
)
devices.append(status)
return devices
def get_ready_devices(self) -> list[DeviceStatus]:
"""Return devices in authorized 'device' state."""
return [d for d in self.list_devices() if d.state == "device"]
def get_idle_devices(self) -> list[DeviceStatus]:
"""Return ready devices that are not currently holding an execution lock."""
return [d for d in self.list_devices() if d.state == "device" and not d.is_busy]
def select_device(self, preferred_serial: str | None = None) -> str | None:
"""Select a target device serial for task execution.
If `preferred_serial` is provided:
Returns preferred_serial if it is present or online.
Otherwise:
Selects the first idle/unlocked ready device. If all are busy,
selects the first ready device so the task will queue on it.
Returns None if no devices are attached.
"""
all_devs = self.list_devices()
ready_devs = [d for d in all_devs if d.state == "device"]
if preferred_serial:
# Check if preferred is available or exists
for dev in all_devs:
if dev.serial == preferred_serial:
return dev.serial
# If not detected by ADB, still return it so ADB can attempt connection
return preferred_serial
# Pick first idle device
for dev in ready_devs:
if not dev.is_busy:
return dev.serial
# If all busy, return the first ready device (it will queue)
if ready_devs:
return ready_devs[0].serial
# Fallback to any attached device if none ready
if all_devs:
return all_devs[0].serial
return None
# Global device pool instance
device_pool = DevicePool()
+318
View File
@@ -0,0 +1,318 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Artemis Server Lifecycle and Process Management.
Provides cross-platform facilities to discover, inspect, terminate,
and restart running Artemis UI / backend server instances regardless
of which terminal or process spawned them.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
import shutil
import socket
import subprocess
import sys
import time
from typing import Any
from artemis.config.paths import ROOT_DIR, get_server_info_file
from artemis.runtime.device_lock import DeviceExecutionLock
from artemis.runtime.supervisor import ProcessSupervisor
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
def is_port_in_use(port: int, host: str = "127.0.0.1", timeout: float = 0.5) -> bool:
"""Check whether a TCP port is currently listening / occupied."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.settimeout(timeout)
try:
return s.connect_ex((host, port)) == 0
except Exception:
return False
def write_server_info(
port: int,
host: str = "0.0.0.0",
pid: int | None = None,
) -> Path:
"""Persist current Artemis server runtime metadata to well-known path."""
info_file = get_server_info_file()
try:
info_file.parent.mkdir(parents=True, exist_ok=True)
data = {
"pid": pid or os.getpid(),
"port": port,
"host": host,
"started_at": time.time(),
"cwd": str(ROOT_DIR),
"sys_executable": sys.executable,
"cmdline": sys.argv,
}
info_file.write_text(json.dumps(data, indent=2), encoding="utf-8")
logger.debug(f"Saved Artemis server info (PID: {data['pid']}, port: {port}) to {info_file}")
except Exception as e:
logger.warning(f"Could not persist server metadata to {info_file}: {e}")
return info_file
def read_server_info() -> dict[str, Any] | None:
"""Read and validate the persisted server runtime metadata if present."""
info_file = get_server_info_file()
if not info_file.exists():
return None
try:
content = info_file.read_text(encoding="utf-8").strip()
if not content:
return None
data = json.loads(content)
if isinstance(data, dict) and "pid" in data:
return data
except Exception as e:
logger.debug(f"Failed to read server info from {info_file}: {e}")
return None
def clear_server_info() -> None:
"""Remove the persisted server metadata file if it exists."""
info_file = get_server_info_file()
try:
if info_file.exists():
info_file.unlink()
logger.debug(f"Cleared server info file at {info_file}")
except Exception as e:
logger.debug(f"Error clearing server info file {info_file}: {e}")
def find_server_pids(port: int = 8000) -> list[int]:
"""Find all process IDs associated with the Artemis server listening on `port`.
Uses a multi-tiered discovery strategy:
1. Check persisted server metadata file.
2. Check port listeners via lsof (macOS/Linux), fuser (Linux), or netstat (Windows).
3. Fallback to psutil net_connections and process table scan.
"""
discovered: set[int] = set()
# 1. Check metadata file
info = read_server_info()
if info and info.get("port") == port:
saved_pid = info.get("pid")
if saved_pid and isinstance(saved_pid, int):
try:
import psutil
if psutil.pid_exists(saved_pid):
discovered.add(saved_pid)
except Exception:
try:
os.kill(saved_pid, 0)
discovered.add(saved_pid)
except OSError:
pass
# 2. Check port listeners with platform-native tools
# 2a. lsof (macOS & Linux)
if shutil.which("lsof"):
try:
res = subprocess.run(
["lsof", "-ti", f":{port}", "-sTCP:LISTEN"],
capture_output=True,
text=True,
timeout=2.0,
)
if res.returncode == 0 and res.stdout.strip():
for token in res.stdout.strip().splitlines():
token = token.strip()
if token.isdigit():
discovered.add(int(token))
except Exception:
pass
# 2b. fuser (Linux fallback)
if not discovered and shutil.which("fuser"):
try:
res = subprocess.run(
["fuser", f"{port}/tcp"],
capture_output=True,
text=True,
timeout=2.0,
)
for token in res.stdout.strip().split():
token = token.strip()
if token.isdigit():
discovered.add(int(token))
except Exception:
pass
# 2c. netstat (Windows fallback)
if not discovered and sys.platform == "win32":
try:
res = subprocess.run(
["netstat", "-ano"],
capture_output=True,
text=True,
timeout=3.0,
)
if res.returncode == 0:
for line in res.stdout.splitlines():
if f":{port}" in line and "LISTENING" in line.upper():
parts = line.strip().split()
if parts and parts[-1].isdigit():
discovered.add(int(parts[-1]))
except Exception:
pass
# 2d. psutil net_connections fallback
if not discovered:
try:
import psutil
for conn in psutil.net_connections(kind="inet"):
if conn.laddr and conn.laddr.port == port:
if conn.pid:
discovered.add(conn.pid)
except Exception:
pass
# 3. Process table scan if still nothing found on port but port is in use
if not discovered and is_port_in_use(port):
try:
import psutil
for proc in psutil.process_iter(["pid", "cmdline"]):
try:
cmdline = " ".join(proc.info.get("cmdline") or [])
if "apps.admin_console.server" in cmdline or (
"artemis" in cmdline and "ui" in cmdline
):
if proc.info.get("pid"):
discovered.add(proc.info["pid"])
except (psutil.NoSuchProcess, psutil.AccessDenied):
continue
except Exception:
pass
return sorted(discovered)
def get_server_status(port: int = 8000) -> dict[str, Any]:
"""Retrieve detailed status of the Artemis server on given port."""
in_use = is_port_in_use(port)
pids = find_server_pids(port)
info = read_server_info()
uptime_seconds: float | None = None
if info and "started_at" in info and isinstance(info["started_at"], (int, float)):
uptime_seconds = max(0.0, time.time() - info["started_at"])
is_running = in_use or bool(pids)
return {
"running": is_running,
"port": port,
"pids": pids,
"active_pid": pids[0] if pids else (info.get("pid") if info else None),
"uptime_seconds": uptime_seconds,
"url": f"http://localhost:{port}" if is_running else None,
"admin_url": f"http://localhost:{port}/admin" if is_running else None,
"metadata": info,
}
def stop_server(
port: int = 8000,
timeout: float = 4.0,
force: bool = False,
) -> tuple[bool, str, list[int]]:
"""Gracefully terminate any running Artemis server on `port`.
Args:
port: TCP port to inspect and terminate.
timeout: Maximum seconds to wait for graceful process exit.
force: If True, immediately send SIGKILL without waiting.
Returns:
tuple (success, message, affected_pids)
"""
pids = find_server_pids(port)
if not pids and not is_port_in_use(port):
clear_server_info()
DeviceExecutionLock.cleanup_stale_locks()
return True, f"No active server detected on port {port}.", []
# If no PID was discovered but port is still in use, try one more aggressive discovery
if not pids and is_port_in_use(port):
time.sleep(0.2)
pids = find_server_pids(port)
stopped_pids: list[int] = []
current_pid = os.getpid()
for pid in pids:
if pid == current_pid:
# Don't terminate self during stop_server call
continue
try:
if force:
ProcessSupervisor.terminate_tree(pid, timeout_seconds=0.5)
else:
ProcessSupervisor.terminate_tree(pid, timeout_seconds=timeout)
stopped_pids.append(pid)
except Exception as e:
logger.warning(f"Failed to terminate process {pid}: {e}")
# Wait for the port to actually become free
deadline = time.time() + max(1.5, timeout)
while time.time() < deadline:
if not is_port_in_use(port):
break
time.sleep(0.15)
# Force kill if port is still occupied
if is_port_in_use(port) and not force:
remaining_pids = find_server_pids(port)
for pid in remaining_pids:
if pid != current_pid:
try:
ProcessSupervisor.terminate_tree(pid, timeout_seconds=0.5)
except Exception:
pass
time.sleep(0.3)
# Cleanup stale device execution locks left by terminated server
cleaned_locks = DeviceExecutionLock.cleanup_stale_locks()
clear_server_info()
port_free = not is_port_in_use(port)
if port_free:
pid_str = f" (PID: {', '.join(map(str, stopped_pids))})" if stopped_pids else ""
msg = f"Artemis server stopped{pid_str}. Port {port} is released."
if cleaned_locks:
msg += f" Cleaned up {cleaned_locks} device lock(s)."
return True, msg, stopped_pids
else:
return (
False,
f"Port {port} remains occupied after stop attempt. Some background process may require manual termination.",
stopped_pids,
)
+110 -18
View File
@@ -106,8 +106,46 @@ class Agent:
_task_lock: asyncio.Lock
_cloud_mobile_id: str | None = None
def __init__(self, *, config: AgentConfig | None = None):
self._config = config or get_default_agent_config()
def __init__(
self,
*,
config: AgentConfig | None = None,
device_id: str | None = None,
device_serial: str | None = None,
concurrency_mode: str | None = None,
max_concurrency: int | None = None,
session_id: str | None = None,
):
raw_sid = (
session_id
or os.getenv("ARTEMIS_SESSION_ID")
or os.getenv("ARTEMIS_CLOUD_SESSION_ID")
)
self._session_id: str | None = str(raw_sid).strip() if raw_sid else None
target_dev = device_serial or device_id
if config is None:
from artemis.sdk.builders import Builders
builder = Builders.AgentConfig
if target_dev:
builder.for_device(DevicePlatform.ANDROID, target_dev)
if concurrency_mode:
builder.with_concurrency_mode(concurrency_mode)
if max_concurrency is not None:
builder.with_max_concurrency(max_concurrency)
self._config = builder.build()
else:
self._config = config
updates = {}
if target_dev:
updates["device_id"] = target_dev
updates["device_platform"] = DevicePlatform.ANDROID
if concurrency_mode:
updates["concurrency_mode"] = str(concurrency_mode).strip().lower()
if max_concurrency is not None:
updates["max_concurrency"] = max_concurrency
if updates:
self._config = self._config.model_copy(update=updates)
self._tasks = []
self._tmp_traces_dir = Path(settings.TRACES_PATH)
self._initialized = False
@@ -116,9 +154,16 @@ class Agent:
async def init(
self,
api_key: str | None = None,
device_id: str | None = None,
device_serial: str | None = None,
retry_count: int = 5,
retry_wait_seconds: int = 5,
):
target_dev = device_serial or device_id
if target_dev:
self._config = self._config.model_copy(
update={"device_id": target_dev, "device_platform": DevicePlatform.ANDROID}
)
return await self._init_internal(
api_key=api_key,
@@ -187,7 +232,7 @@ class Agent:
async def _prewarm_llm_connections(self, api_key: str | None = None):
"""Pre-warms the HTTP2/gRPC connection pools for both Native GenAI and LangChain clients in the background."""
publish_startup_progress("model_warmup", "Warming the model connection")
publish_startup_progress("model_warmup", "Warming the model connection", session_id=self._session_id)
logger.info("Starting background pre-warming of Gemini API connection pools...")
try:
key = api_key
@@ -197,7 +242,7 @@ class Agent:
if not key:
logger.warning("Skipping LLM pre-warming: No API key available.")
publish_startup_progress(
"model_ready", "Model connection will initialize on first use"
"model_ready", "Model connection will initialize on first use", session_id=self._session_id
)
return
@@ -214,11 +259,11 @@ class Agent:
return_exceptions=True,
)
logger.success("Gemini API connection pools successfully pre-warmed.")
publish_startup_progress("model_ready", "Model connection is ready")
publish_startup_progress("model_ready", "Model connection is ready", session_id=self._session_id)
except Exception as e:
logger.warning(f"Failed to pre-warm LLM connections: {e}")
publish_startup_progress(
"model_ready", "Model connection will initialize on first use"
"model_ready", "Model connection will initialize on first use", session_id=self._session_id
)
async def install_apk(self, apk_path: str | Path) -> None:
@@ -437,7 +482,12 @@ class Agent:
logger.info(str(agent_profile))
on_status_changed = None
task_id = str(uuid.uuid4())
task_id = str(
self._session_id
or os.getenv("ARTEMIS_SESSION_ID")
or os.getenv("ARTEMIS_CLOUD_SESSION_ID")
or uuid.uuid4()
)
task = Task(
id=task_id,
@@ -475,12 +525,34 @@ class Agent:
last_state: State | None = None
last_state_snapshot: dict | None = None
output = None
device_lock = DeviceExecutionLock(
effective_mode = getattr(self._config, "concurrency_mode", "per_device")
effective_max = getattr(self._config, "max_concurrency", None)
sess_id = (
self._session_id
or os.getenv("ARTEMIS_SESSION_ID")
or os.getenv("ARTEMIS_CLOUD_SESSION_ID")
or getattr(task, "id", None)
or getattr(getattr(task, "request", None), "task_name", None)
)
active_owner = DeviceExecutionLock.get_active_owner(self._device_context.device_id)
already_held = (
active_owner is not None
and active_owner.pid == os.getpid()
and (
(sess_id and str(active_owner.session_id) == str(sess_id))
or active_owner.token == self._device_context.device_id
)
)
device_lock = None if already_held else DeviceExecutionLock(
self._device_context.device_id,
description=f"automation task: {request.goal[:120]}",
description=f"{request.goal[:120]}",
concurrency_mode=effective_mode,
max_concurrency=effective_max,
session_id=str(sess_id) if sess_id else None,
ingress="agent",
)
try:
if os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
if device_lock is not None and os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
queue_cancel_event = threading.Event()
acquire_task = asyncio.create_task(
asyncio.to_thread(
@@ -503,21 +575,21 @@ class Agent:
# DataEngine uses a shared database, so a queued task must not
# publish a new active session while the current task is still
# finishing.
self._prepare_tracing(task=task, context=context)
self._prepare_output_files(task=task)
if os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
if self._ui_adb_client is not None:
publish_startup_progress(
"uiautomator", "Connecting to UI Automator"
"uiautomator", "Connecting to UI Automator", session_id=str(task.id)
)
await asyncio.to_thread(self._ui_adb_client.connect)
publish_startup_progress(
"uiautomator_ready", "UI Automator is ready"
"uiautomator_ready", "UI Automator is ready", session_id=str(task.id)
)
await self._ensure_device_unlocked()
publish_startup_progress(
"environment", "Preparing the device environment"
"environment", "Preparing the device environment", session_id=str(task.id)
)
self._prepare_tracing(task=task, context=context)
self._prepare_output_files(task=task)
await self._prepare_app_installation(task=task)
await self._prepare_device_environment(context=context)
await self._prepare_app_lock(task=task, context=context)
@@ -548,7 +620,7 @@ class Agent:
logger.error(f"[{task_name}] Failed to start screen recording: {e}")
publish_startup_progress(
"environment_ready", "Device environment is ready"
"environment_ready", "Device environment is ready", session_id=str(task.id)
)
try:
if request.profile and request.profile.lower() == "flash":
@@ -707,7 +779,8 @@ class Agent:
if self._ui_adb_client is not None:
await asyncio.to_thread(self._ui_adb_client.disconnect)
finally:
await asyncio.to_thread(device_lock.release)
if device_lock is not None:
await asyncio.to_thread(device_lock.release)
async with self._task_lock:
if self._current_task and not self._current_task.done():
@@ -919,7 +992,26 @@ class Agent:
device_data = context.device.model_dump() if context.device else {}
if task.request.profile:
device_data["profile"] = task.request.profile
context.data_engine.start_session(goal=task.request.goal, device_info=device_data)
target_sid = (
self._session_id
or os.getenv("ARTEMIS_SESSION_ID")
or os.getenv("ARTEMIS_CLOUD_SESSION_ID")
or getattr(task, "id", None)
or getattr(getattr(task, "request", None), "task_name", None)
)
sess_uuid = None
if target_sid:
try:
sess_uuid = uuid.UUID(str(target_sid))
except Exception:
sess_uuid = str(target_sid)
context.data_engine.start_session(
goal=task.request.goal,
device_info=device_data,
session_id=sess_uuid,
)
async def _finalize_tracing_safely(self, task: Task, context: ArtemisContext):
"""Finalize optional trace artifacts without changing task semantics."""
+35 -6
View File
@@ -69,6 +69,8 @@ class AgentConfigBuilder:
self._force_web_accessibility: bool = False
self._disable_checker: bool = True
self._cloud_mobile_id_or_ref: str | None = None
self._concurrency_mode: str = "per_device"
self._max_concurrency: int | None = None
agent_cfg = load_agent_config()
self._explorer = agent_cfg.explorer
@@ -128,14 +130,19 @@ class AgentConfigBuilder:
def for_device(
self,
platform: DevicePlatform,
device_id: str,
platform_or_device_id: DevicePlatform | str,
device_id: str | None = None,
) -> "AgentConfigBuilder":
"""Configure the ARTEMIS agent for a specific device.
Supports both:
builder.for_device(DevicePlatform.ANDROID, "emulator-5554")
and:
builder.for_device("emulator-5554") (defaults to DevicePlatform.ANDROID)
Args:
platform: The device platform (ANDROID)
device_id: The unique identifier for the device
platform_or_device_id: DevicePlatform or unique identifier for the device
device_id: The unique identifier for the device (if platform was passed first)
"""
if self._cloud_mobile_id_or_ref is not None:
raise ValueError(
@@ -143,8 +150,28 @@ class AgentConfigBuilder:
" configured.\n> for_device() and for_cloud_mobile() are"
" mutually exclusive"
)
self._device_id = device_id
self._device_platform = platform
if isinstance(platform_or_device_id, DevicePlatform):
self._device_platform = platform_or_device_id
self._device_id = device_id
else:
self._device_platform = DevicePlatform.ANDROID
self._device_id = str(platform_or_device_id)
return self
def for_device_serial(self, device_serial: str) -> "AgentConfigBuilder":
"""Target a specific Android device by its ADB serial number."""
return self.for_device(DevicePlatform.ANDROID, device_serial)
def with_concurrency_mode(
self, mode: str
) -> "AgentConfigBuilder":
"""Configure concurrency mode: 'global' (1 task globally) or 'per_device' (1 task per device)."""
self._concurrency_mode = str(mode).strip().lower()
return self
def with_max_concurrency(self, max_concurrency: int) -> "AgentConfigBuilder":
"""Configure max concurrent tasks limit."""
self._max_concurrency = max_concurrency
return self
def with_default_task_config(self, config: TaskRequestCommon) -> "AgentConfigBuilder":
@@ -511,6 +538,8 @@ class AgentConfigBuilder:
video_analyzer=self._video_analyzer.model_copy(
update={"enable_ledger": self._enable_video_ledger}
),
concurrency_mode=self._concurrency_mode,
max_concurrency=self._max_concurrency,
)
+1 -1
View File
@@ -57,7 +57,7 @@ class TaskRequestCommonBuilder(BaseModel):
def __init__(self):
self._max_steps = RECURSION_LIMIT
self._record_trace = True
self._trace_path = Path("artemis-traces")
self._trace_path = Path("traces")
self._llm_output_path: Path | None = None
self._locked_app_package: str | None = None
self._app_path: Path | None = None
+2 -1
View File
@@ -123,12 +123,13 @@ class AgentConfig(BaseModel):
flash: FlashProfileConfig = Field(default_factory=FlashProfileConfig)
pro: ProProfileConfig = Field(default_factory=ProProfileConfig)
explorer: ExplorerConfig = Field(default_factory=ExplorerConfig)
explorer_versions: dict[str, Literal["flash", "pro", "ultra"]]
denylisted_tools: dict[str, list[str]]
enable_video_ledger: bool = True
video_analyzer: VideoAnalyzerConfig = Field(default_factory=VideoAnalyzerConfig)
concurrency_mode: Literal["global", "per_device"] = "per_device"
max_concurrency: int | None = None
model_config = {"arbitrary_types_allowed": True}
+1 -1
View File
@@ -96,7 +96,7 @@ class TaskRequestBase(BaseModel):
model_config = {"ignored_types": (CyFunctionDetector,)}
max_steps: int = RECURSION_LIMIT
record_trace: bool = True
trace_path: Path = Path("artemis-traces")
trace_path: Path = Path("traces")
llm_output_path: Path | None = None
+18
View File
@@ -14,9 +14,27 @@
"""Managed Toolchain and Binary Discovery Subsystem."""
import os
from pathlib import Path
from artemis.toolchain.descriptors import TOOLS, ToolDescriptor
from artemis.toolchain.resolver import ToolchainResolver, toolchain
def ensure_toolchain_in_path() -> None:
"""Prepend resolved toolchain paths (e.g. adb, scrcpy, ffmpeg) to process PATH."""
for tool_name in ("adb", "ffmpeg", "scrcpy"):
tool_path = toolchain.resolve(tool_name)
if tool_path:
parent_dir = str(Path(tool_path).parent)
current_path = os.environ.get("PATH", "")
if parent_dir not in current_path.split(os.pathsep):
os.environ["PATH"] = f"{parent_dir}{os.pathsep}{current_path}"
# Auto-configure process PATH upon import
ensure_toolchain_in_path()
find_adb = toolchain.find_adb
find_ffmpeg = toolchain.find_ffmpeg
find_scrcpy = toolchain.find_scrcpy
+4 -4
View File
@@ -24,17 +24,17 @@ from typing import Any
from artemis.config import read_ipc_port
def publish_startup_progress(stage: str, message: str, **details: Any) -> None:
def publish_startup_progress(stage: str, message: str, session_id: str | None = None, **details: Any) -> None:
"""Publish one startup milestone, ignoring unavailable UI/IPC endpoints."""
session_id = os.getenv("ARTEMIS_SESSION_ID") or os.getenv(
resolved_session_id = session_id or os.getenv("ARTEMIS_SESSION_ID") or os.getenv(
"ARTEMIS_CLOUD_SESSION_ID"
)
ipc_port = read_ipc_port()
if not session_id or not ipc_port:
if not resolved_session_id or not ipc_port:
return
event_data = {
"session_id": session_id,
"session_id": str(resolved_session_id),
"stage": stage,
"message": message,
"timestamp": time.time(),
+11 -4
View File
@@ -68,10 +68,17 @@ When you run `uv run artemis mcp --install all` (or target a specific IDE like `
## 🛠️ MCP Tools Overview
* **`mobile_run_task`**: Asynchronously launches an autonomous mobile automation task (`Flash` or `Pro` model).
* **`mobile_manage_task`**: Manages task lifecycle (`status`, `stop`, `inject_instruction`).
* **`mobile_get_device_state`**: Real-time observer (`screenshot` or OCR+XML `hierarchy`).
* **`mobile_inspect_trace`**: Granular trace inspection, visual action overlays, and agent reasoning.
* **`mobile_run_task`**: Asynchronously launches an autonomous mobile automation task (`Flash` or `Pro` model) with optional `device_serial` targeting.
* **`mobile_manage_task`**: Manages task lifecycle (`status`, `stop`, `inject_instruction`), returning task state and assigned `device_serial`.
* **`mobile_get_device_state`**: Real-time observer (`screenshot` or OCR+XML `hierarchy`) with optional `device_serial`.
* **`mobile_inspect_trace`**: Granular trace inspection, visual action overlays, agent reasoning, and `device_serial` tracking.
### 📱 Device Selection & Multi-Device Execution
ARTEMIS supports parallel execution across multiple connected Android devices and emulators:
1. **Direct Device Specification**: Provide `device_serial` explicitly (e.g. `device_serial="63191FDKX00062"` or `device_serial="emulator-5554"`). Per-device execution locks allow distinct devices to run in parallel.
2. **Automatic Device Selection**: Omit `device_serial` to let ARTEMIS auto-select an available ready device from the device pool.
* **User Choice Priority**: When multiple devices/emulators are connected, AI agents should prioritize asking the user which device to run on.
* **Device Diagnosis**: Inspect connected hardware, authorization status, and serials anytime via `adb devices` or `adb devices -l`.
## 🚀 How to Run & Configure
+59 -17
View File
@@ -114,6 +114,7 @@ async def run_task(
locked_app_package: str | None = None,
app_path: str | None = None,
expected_output_desc: str | None = None,
device_serial: str | None = None,
):
"""Executes the mobile automation agent task and logs all actions/results."""
trace_dir = trace_store.get_trace_dir(trace_id)
@@ -142,6 +143,7 @@ async def run_task(
agent = None
adb_path = device_utils.resolve_adb_path()
target_serial = device_serial
try:
from artemis.sdk import Agent
@@ -150,15 +152,36 @@ async def run_task(
connected_devices = device_utils.get_connected_devices(adb_path)
if connected_devices:
print(
f"✅ Detected active connected device(s): {connected_devices}. "
f"Using the first available device: '{connected_devices[0]}'."
)
if device_serial:
target_serial = device_serial
if connected_devices and device_serial not in connected_devices:
print(
f"⚠️ Warning: Specified device serial '{device_serial}' was not detected in active ADB devices: {connected_devices}. "
"Proceeding with target serial (will attempt direct ADB connection)..."
)
else:
print(f"✅ Using specified target device: '{device_serial}'.")
else:
print("❌ No active connected devices detected. Booting emulator...")
if not device_utils.ensure_emulator(adb_path=adb_path):
raise RuntimeError("Failed to start or connect to the Android emulator.")
if connected_devices:
target_serial = settings.ADB_DEVICE_SERIAL or os.environ.get("ADB_DEVICE_SERIAL")
if not target_serial:
try:
from artemis.runtime import device_pool
target_serial = device_pool.select_device()
except Exception:
target_serial = connected_devices[0]
print(
f"✅ Detected active connected device(s): {connected_devices}. "
f"Auto-selected device: '{target_serial}'."
)
else:
print("❌ No active connected devices detected. Booting emulator...")
if not device_utils.ensure_emulator(adb_path=adb_path):
raise RuntimeError("Failed to start or connect to the Android emulator.")
target_serial = "emulator-5554"
if target_serial:
trace_store.update_trace_device_serial(trace_id, target_serial)
print("Initializing Artemis Agent...")
from artemis.config import initialize_llm_config, settings
@@ -173,6 +196,10 @@ async def run_task(
if settings.ADB_HOST:
config_builder.with_adb_server(host=settings.ADB_HOST, port=settings.ADB_PORT)
if target_serial:
from artemis.context import DevicePlatform
config_builder.for_device(DevicePlatform.ANDROID, target_serial)
config = config_builder.build()
agent = Agent(config=config)
@@ -183,6 +210,12 @@ async def run_task(
timeout_seconds=float(os.getenv("ARTEMIS_AGENT_INIT_TIMEOUT_SECONDS", 30)),
)
actual_serial = getattr(getattr(agent, "_device_context", None), "device_id", None) or target_serial
if actual_serial:
target_serial = actual_serial
trace_store.update_trace_device_serial(trace_id, actual_serial)
print(f"📱 Bound to device serial: '{actual_serial}'")
print("Running task on agent...")
task_builder = (
agent.new_task(goal=task_desc)
@@ -201,17 +234,20 @@ async def run_task(
result = await agent.run_task(request=task_builder.build())
print(f"Task completed. Result: {result}")
device_info_line = f"Device Serial: `{target_serial}`\n" if target_serial else ""
if isinstance(result, dict) and "status" in result and result.get("status") != "completed":
error_explanation = result.get("explanation", "Task execution returned failed status.")
print(f"Task finished with non-completed status: {error_explanation}", file=sys.stderr)
trace_store.update_trace_status(
trace_id, "failed", error=error_explanation, result=result
trace_id, "failed", error=error_explanation, result=result, device_serial=target_serial
)
formatted_result = json.dumps(result, indent=2, ensure_ascii=False)
failure_msg = (
f"Artemis background task finished with non-completed status.\n\n"
f"Trace ID: {trace_id}\n"
f"{device_info_line}"
f"Goal: {task_desc}\n"
f"Explanation: {error_explanation}\n\n"
f"Result:\n```json\n{formatted_result}\n```\n\n"
@@ -223,7 +259,7 @@ async def run_task(
message=failure_msg,
title="Artemis Task Incomplete",
event_type="failed",
payload={"trace_id": trace_id, "goal": task_desc, "result": result},
payload={"trace_id": trace_id, "device_serial": target_serial, "goal": task_desc, "result": result},
)
return
@@ -236,7 +272,7 @@ async def run_task(
"notes_dir to view more details."
)
trace_store.update_trace_status(trace_id, "completed", result=result)
trace_store.update_trace_status(trace_id, "completed", result=result, device_serial=target_serial)
if isinstance(result, dict):
formatted_result = json.dumps(result, indent=2, ensure_ascii=False)
@@ -248,6 +284,7 @@ async def run_task(
success_msg = (
"Artemis background task completed successfully.\n\n"
f"Trace ID: {trace_id}\n"
f"{device_info_line}"
f"Goal: {task_desc}\n"
f"{result_str}"
)
@@ -256,21 +293,22 @@ async def run_task(
message=success_msg,
title="Artemis Task Completed",
event_type="completed",
payload={"trace_id": trace_id, "goal": task_desc, "result": result},
payload={"trace_id": trace_id, "device_serial": target_serial, "goal": task_desc, "result": result},
)
except asyncio.CancelledError:
print("Task was cancelled (asyncio.CancelledError)", file=sys.stderr)
trace_store.update_trace_status(trace_id, "cancelled", error="Task was cancelled")
trace_store.update_trace_status(trace_id, "cancelled", error="Task was cancelled", device_serial=target_serial)
device_info_line = f"Device Serial: `{target_serial}`\n" if target_serial else ""
cancel_msg = (
f"Artemis background task was cancelled.\n\nTrace ID: {trace_id}\nGoal: {task_desc}\n"
f"Artemis background task was cancelled.\n\nTrace ID: {trace_id}\n{device_info_line}Goal: {task_desc}\n"
)
notify(
conversation_id=conversation_id,
message=cancel_msg,
title="Artemis Task Cancelled",
event_type="cancelled",
payload={"trace_id": trace_id, "goal": task_desc},
payload={"trace_id": trace_id, "device_serial": target_serial, "goal": task_desc},
)
raise
@@ -289,11 +327,13 @@ async def run_task(
frame_summary = f" (at {tb.tb_frame.f_code.co_name}:{tb.tb_lineno})" if tb else ""
full_error_desc = f"{error_type}: {raw_error_msg}{frame_summary}"
trace_store.update_trace_status(trace_id, "failed", error=full_error_desc)
trace_store.update_trace_status(trace_id, "failed", error=full_error_desc, device_serial=target_serial)
device_info_line = f"Device Serial: `{target_serial}`\n" if target_serial else ""
failure_msg = (
f"Artemis background task failed.\n\n"
f"Trace ID: {trace_id}\n"
f"{device_info_line}"
f"Goal: {task_desc}\n"
f"Error: {full_error_desc}\n\n"
f'Check the logs using mobile_inspect_trace(action="view_summary", trace_id="{trace_id}") '
@@ -304,7 +344,7 @@ async def run_task(
message=failure_msg,
title="Artemis Task Failed",
event_type="failed",
payload={"trace_id": trace_id, "goal": task_desc, "error": full_error_desc},
payload={"trace_id": trace_id, "device_serial": target_serial, "goal": task_desc, "error": full_error_desc},
)
finally:
@@ -330,6 +370,7 @@ if __name__ == "__main__":
parser.add_argument("--locked-app-package", help="Package name of app to lock execution to")
parser.add_argument("--app-path", help="Path to local APK to install before running task")
parser.add_argument("--expected-output-desc", help="Expected output description")
parser.add_argument("--device-serial", help="Target specific device serial")
args = parser.parse_args()
@@ -342,5 +383,6 @@ if __name__ == "__main__":
locked_app_package=args.locked_app_package,
app_path=args.app_path,
expected_output_desc=args.expected_output_desc,
device_serial=args.device_serial,
)
)
+11 -4
View File
@@ -29,10 +29,17 @@ Deeply understand and select between ARTEMIS's dual execution models (**ARTEMIS
- **Safety Net & Self-Healing (Failure Analyzer)**: Every action is vetted by an execution safety net. If an action fails or is blocked, the **Failure Analyzer** is triggered to run local step-by-step recovery sequences, achieving autonomous self-healing. This loop continues iteratively until the task terminates.
- **Outputter (Optional)**: Synthesizes the entire execution trace into a human-readable report detailing every action step and visual result.
### 3. Device & Environment Constraints
- **ADB & File Transfer**: While ARTEMIS excels at device automation, it operates within the device boundary. To extract diagnostic files, logs, or test artifacts from the mobile device to the host PC for further analysis, you must manually write and execute appropriate `adb` commands (e.g., `adb pull`).
- **Hardware Prerequisites**: Running ARTEMIS requires a physically connected, fully authorized Android device (e.g., a Pixel phone) or an active emulator.
- **Single-Device Policy**: ARTEMIS can only operate a single device at any given time. Ensure your workflows and code respect this concurrency limit.
### 3. Device & Environment Constraints & Multi-Device Management
- **Device Selection & Multi-Device Execution**: ARTEMIS supports multi-device execution and per-device concurrency. You can control device targeting via two modes:
- **Direct Device Specification**: Explicitly provide the target phone's serial number via `device_serial` to `mobile_run_task` or `mobile_get_device_state`. Tasks targeting distinct devices run concurrently without blocking each other.
- **Automatic Device Selection**: When `device_serial` is omitted or set to `None`, ARTEMIS automatically selects an available connected device or allocates an idle device from the device pool.
- **Prioritize User Choice for Device Selection**:
- When multiple connected devices or emulators are detected, or whenever device selection is ambiguous, **YOU MUST PRIORITIZE ASKING THE USER** to select or confirm their preferred device serial before launching a task.
- **Device Diagnosis with `adb devices`**:
- Use `adb devices` (or `adb devices -l`) via bash command execution to diagnose attached hardware, inspect connection status (`device`, `unauthorized`, `offline`), and retrieve device serials and models whenever preparing tasks or troubleshooting device issues.
- **ADB & File Transfer**: While ARTEMIS excels at device automation, it operates within the device boundary. To extract diagnostic files, logs, or test artifacts from the mobile device to the host PC for further analysis, you must manually execute appropriate `adb` commands (e.g., `adb -s <serial> pull ...`).
- **Hardware Prerequisites**: Running ARTEMIS requires at least one physically connected, fully authorized Android device (e.g., a Pixel phone) or an active emulator.
- **Per-Device Mutual Exclusion**: ARTEMIS manages per-device execution mutexes (`DeviceExecutionLock`). A device can only execute a single task at a time (FIFO queue), while different devices can execute tasks in parallel.
### 4. Robust Test Code Design (The "Dynamic-First, Coordinate-Fallback" Philosophy)
*If your task involves authoring test code, you must adhere to the following design principles for maximum reliability:*
+21 -9
View File
@@ -31,25 +31,37 @@ from artemis.utils.visualization import format_minimal_list_with_elements
@mcp.tool()
async def mobile_get_device_state(view_type: str) -> str:
async def mobile_get_device_state(view_type: str, device_serial: str | None = None) -> str:
"""Real-time mobile device state observer (for debugging and validation).
Use this tool to inspect the latest mobile status or to assist in tracking
the subagent's task progress.
Retrieves a real-time screenshot (returns local file URI) or a simplified
element tree text list of the currently connected mobile device.
element tree text list of the target mobile device.
### Device Selection Modes:
1. **Direct Device Specification**: Provide `device_serial` to observe a specific
phone or emulator (e.g. `device_serial="63191FDKX00062"`).
2. **Automatic Device Selection**: Omit `device_serial` or leave it `None` to
automatically inspect the default/connected device.
**Diagnostics & User Preference**:
- When multiple devices are attached, prioritize letting the user specify or confirm the device.
- Run `adb devices` to check connected devices and identify their serial numbers.
Args:
view_type: The observation type. Supported values: - "screenshot":
Captures the real-time mobile screen and saves it to the workspace,
returning the local URI path of the image file (e.g.,
"file:///path/to/screenshot.jpg"). - "hierarchy": Directly returns the
simplified text-labeled element list, which is identical to what the
mobile UI automation subagent sees when making decisions.
view_type: The observation type. Supported values:
- "screenshot": Captures the real-time mobile screen and saves it to the workspace,
returning the local URI path of the image file (e.g., "file:///path/to/screenshot.jpg").
- "hierarchy": Directly returns the simplified text-labeled element list, which is
identical to what the mobile UI automation subagent sees when making decisions.
device_serial: Optional Android device serial number (e.g., "63191FDKX00062" or
"emulator-5554"). If specified, inspects state from that specific device. If omitted,
inspects the default connected device.
"""
try:
controller = _get_controller()
controller = _get_controller(device_serial=device_serial)
device_width = controller.ctx.device.device_width
device_height = controller.ctx.device.device_height
except Exception as e:
+38 -8
View File
@@ -115,20 +115,21 @@ async def mobile_inspect_trace(action: str, trace_id: str, step_number: int | No
Use this tool DURING a running task or AFTER it has finished (or failed) to
monitor progress, verify answers, diagnose agent errors, and debug.
It provides comprehensive runtime information, raw agent outputs, and visual
screenshots for both **Flash** and **Pro** tasks.
It provides comprehensive runtime information (including the assigned
`device_serial`), raw agent outputs, and visual screenshots for both
**Flash** and **Pro** tasks.
### Available Actions:
- **'view_summary'**: Retrieves a high-level execution summary across all
steps.
steps, including the target `device_serial`.
* **Pro Model**: Displays a hierarchical task plan summary, showing the
status and descriptions of all completed, active, and pending steps.
status, device serial, and descriptions of all completed, active, and pending steps.
* **Flash Model**: Displays an all-in-one execution chain containing the
complete reasoning (thoughts/motivation) and action taken (`click`,
complete reasoning (thoughts/motivation), device serial, and action taken (`click`,
`input_text`, etc.) for every single step.
- **'view_step_screenshots'**: Retrieves local file paths of screenshots for
a specific step (supports both Flash and Pro). Returns:
a specific step (supports both Flash and Pro), along with `device_serial`. Returns:
1. `before_screenshot`: The original device screenshot observed by the
agent right before taking the action.
2. `after_screenshot`: A post-action screenshot capturing the failure or
@@ -139,9 +140,10 @@ async def mobile_inspect_trace(action: str, trace_id: str, step_number: int | No
action visually marked (e.g., red circle for taps, red arrow for
swipes). CRITICAL for verifying if the agent tapped the correct UI
element.
4. `device_serial`: The serial of the device on which this step was executed.
- **'view_step_details'**: Retrieves deep runtime logs, raw VLM thoughts,
and exact actions for a specific step.
and exact actions for a specific step, along with `device_serial`.
* **Pro Model**: Fully supported. Returns detailed per-step diagnostic
data.
* **Flash Model**: DO NOT USE. (Flash reasoning and actions are already
@@ -242,11 +244,34 @@ async def mobile_inspect_trace(action: str, trace_id: str, step_number: int | No
status_data = trace_store.read_status(trace_id)
is_flash = status_data and status_data.get("model", "").lower() == "flash"
device_serial = status_data.get("device_serial") if status_data else None
if not device_serial and os.path.exists(db_path):
try:
conn_dev = sqlite3.connect(db_path)
conn_dev.row_factory = sqlite3.Row
cur_dev = conn_dev.cursor()
cur_dev.execute(
"SELECT device_info FROM sessions WHERE session_id = ? LIMIT 1",
(trace_id,),
)
row_dev = cur_dev.fetchone()
if row_dev and row_dev["device_info"]:
try:
d_info = json.loads(row_dev["device_info"])
if isinstance(d_info, dict) and d_info.get("device_id"):
device_serial = d_info["device_id"]
except Exception:
pass
conn_dev.close()
except Exception:
pass
device_info_str = f" | **Device Serial:** `{device_serial}`" if device_serial else ""
if is_flash:
summary_lines = [
"# ARTEMIS Flash Execution Summary",
f"**Session ID:** `{trace_id}` | **Model:** Flash\n",
f"**Session ID:** `{trace_id}` | **Model:** Flash{device_info_str}\n",
"---",
"## Step-by-Step Execution Chain\n",
]
@@ -296,6 +321,8 @@ async def mobile_inspect_trace(action: str, trace_id: str, step_number: int | No
last_n_detailed=0,
all_detailed=False,
)
if device_serial and not summary_markdown.startswith("**Session ID:"):
summary_markdown = f"**Session ID:** `{trace_id}` | **Model:** Pro{device_info_str}\n\n" + summary_markdown
return summary_markdown
except Exception as e:
return {
@@ -307,6 +334,7 @@ async def mobile_inspect_trace(action: str, trace_id: str, step_number: int | No
elif action in ("view_step_screenshots", "view_step_details"):
status_data = trace_store.read_status(trace_id)
is_flash = status_data and status_data.get("model", "").lower() == "flash"
device_serial = status_data.get("device_serial") if status_data else None
if action == "view_step_details" and is_flash:
return {
@@ -426,6 +454,7 @@ async def mobile_inspect_trace(action: str, trace_id: str, step_number: int | No
return {
"trace_id": trace_id,
"device_serial": device_serial,
"step_number": step_dict["step_number"],
"before_screenshot": pre_image,
"after_screenshot": post_image,
@@ -451,6 +480,7 @@ async def mobile_inspect_trace(action: str, trace_id: str, step_number: int | No
return {
"trace_id": trace_id,
"device_serial": device_serial,
"step_number": step_dict["step_number"],
"details": rendered_text,
}
+52 -1
View File
@@ -43,7 +43,16 @@ def mobile_manage_task(
### Available Actions:
- **'status'**: Retrieves execution progress, elapsed time, and metadata.
Returns a JSON object.
Returns a JSON object containing:
* `trace_id`: Unique identifier for the task.
* `status`: Task lifecycle state ('running', 'completed', 'failed', 'cancelled').
* `device_serial`: The Android device serial number assigned to/running this task
(essential in multi-device environments to know which phone this task belongs to).
* `task_desc`: The goal/description of the task.
* `model`: 'Flash' or 'Pro'.
* `elapsed_seconds`: Execution duration in seconds.
* `progress`: Live progress data (current turn, latest thoughts and actions for Flash;
active task plan for Pro).
* **How to use the output**:
- For **Pro Model**, check `progress.task_plan` to see if the
agent's long-term plan aligns with your goal.
@@ -113,9 +122,51 @@ def mobile_manage_task(
end_time = status_data.get("end_time") or time.time()
elapsed = round(end_time - start_time, 1) if start_time else 0
project_root = env_utils.get_project_root()
db_path = os.path.join(trace_store.TRACES_DIR, "data_engine.db")
if not os.path.exists(db_path):
db_path = os.path.join(project_root, "traces", "data_engine.db")
device_serial = status_data.get("device_serial")
if not device_serial and os.path.exists(db_path):
try:
conn_dev = sqlite3.connect(db_path)
conn_dev.row_factory = sqlite3.Row
cur_dev = conn_dev.cursor()
cur_dev.execute(
"SELECT device_info FROM sessions WHERE session_id = ? OR pid = ? ORDER BY start_time DESC LIMIT 1",
(trace_id, pid),
)
row_dev = cur_dev.fetchone()
if row_dev and row_dev["device_info"]:
try:
d_info = json.loads(row_dev["device_info"])
if isinstance(d_info, dict) and d_info.get("device_id"):
device_serial = d_info["device_id"]
status_data["device_serial"] = device_serial
trace_store.write_status(trace_id, status_data)
except Exception:
pass
conn_dev.close()
except Exception:
pass
if not device_serial and pid:
try:
owners = DeviceExecutionLock.get_active_owners()
for clean_id, owner in owners.items():
if owner.pid == pid or owner.session_id == trace_id:
device_serial = owner.device_id
status_data["device_serial"] = device_serial
trace_store.write_status(trace_id, status_data)
break
except Exception:
pass
response: dict[str, Any] = {
"trace_id": trace_id,
"status": current_status,
"device_serial": device_serial,
"elapsed_seconds": elapsed,
"task_desc": status_data.get("task_desc"),
"model": status_data.get("model"),
+59 -10
View File
@@ -32,6 +32,7 @@ def mobile_run_task(
locked_app_package: str | None = None,
app_path: str | None = None,
expected_output_desc: str | None = None,
device_serial: str | None = None,
) -> dict[str, Any]:
"""Starts a specialized mobile UI automation subagent to autonomously plan and execute tasks on a connected Android device.
@@ -41,6 +42,23 @@ def mobile_run_task(
straightforward UI scripts) and **Pro** (for complex, exploratory tasks
requiring deep reasoning).
### Device Selection & Multi-Device Support (CRITICAL)
ARTEMIS supports multi-device execution and provides two device selection modes:
1. **Direct Device Specification**: Specify `device_serial` explicitly
(e.g., `device_serial="63191FDKX00062"` or `device_serial="emulator-5554"`).
Tasks targeting different devices run concurrently via per-device locking.
2. **Automatic Device Selection**: If `device_serial` is omitted or `None`,
ARTEMIS will automatically select the connected device or allocate an idle device
from the device pool.
**Device Selection Policy & Diagnostics**:
- **Prioritize User Choice**: When multiple devices or emulators are connected (or when
user intent is not explicitly specified), **ALWAYS PRIORITIZE ASKING THE USER** to
choose or confirm the target device before launching tasks.
- **Device Diagnosis with `adb devices`**: Always use `adb devices` (or `adb devices -l`)
to inspect connected hardware, verify authorization states (`device` vs `unauthorized`),
and retrieve active device serials when diagnosing or before delegating tasks.
### Model Selection Guide (CRITICAL FOR ROUTING)
You MUST choose the appropriate `model` based on the task complexity:
- **Flash**: Best for simple, deterministic tasks that can be completed
@@ -81,6 +99,7 @@ def mobile_run_task(
containing:
- `trace_id`: The unique session identifier of the task for status tracking
and inspection.
- `device_serial`: The assigned target device serial (or "auto-select").
- `notes_dir`: (Pro Model Only) The directory where the subagent's execution
files/notes are located.
- `stdout_log`: The path to the execution framework log.
@@ -120,6 +139,11 @@ def mobile_run_task(
summarization agent will aggregate the interaction history
(motivation, actions, videos) into a summary report saved as
"output.md" in `notes_dir`. Ignored if `model="Flash"`.
device_serial: Optional Android device serial number (e.g. "63191FDKX00062"
or "emulator-5554"). If specified, binds execution strictly to that device,
allowing multi-device concurrent automation. If omitted, ARTEMIS will
automatically select an available device. Prioritize letting the user select
the target device if multiple devices are attached.
"""
# 0. Validate and normalize model
if model.lower() not in ("flash", "pro"):
@@ -135,18 +159,31 @@ def mobile_run_task(
task_desc=task_desc,
model=canonical_model,
conversation_id=conversation_id,
device_serial=device_serial,
)
# 3. Resolve project root, python executable, and background runner module
# 3. Auto-spawn daemon if not already running (unless standalone forced)
if os.environ.get("ARTEMIS_STANDALONE") != "1":
try:
from artemis.runtime import ensure_daemon_running
ensure_daemon_running(timeout=1.5)
except Exception:
pass
# 4. Resolve project root, python executable, and background runner module
project_root = env_utils.get_project_root()
python_exe = env_utils.resolve_python_executable(project_root)
queue_ticket = DeviceExecutionLock.reserve(
description=f"MCP task: {task_desc[:120]}",
session_id=trace_id,
ingress="mcp",
)
reserve_kwargs: dict[str, Any] = {
"description": f"MCP task: {task_desc[:120]}",
"session_id": trace_id,
"ingress": "mcp",
}
if device_serial:
reserve_kwargs["device_id"] = device_serial
queue_ticket = DeviceExecutionLock.reserve(**reserve_kwargs)
# 4. Spawn the background task runner as an independent subprocess
# 5. Spawn the background task runner as an independent subprocess
try:
cmd = [
python_exe,
@@ -167,10 +204,15 @@ def mobile_run_task(
cmd.extend(["--app-path", app_path])
if expected_output_desc and canonical_model != "Flash":
cmd.extend(["--expected-output-desc", expected_output_desc])
if device_serial:
cmd.extend(["--device-serial", device_serial])
env = os.environ.copy()
env["ARTEMIS_SESSION_ID"] = trace_id
env["ARTEMIS_TASK_INGRESS"] = "mcp"
if device_serial:
env["ADB_DEVICE_SERIAL"] = device_serial
env["ARTEMIS_DEVICE_ID"] = device_serial
env[DeviceExecutionLock.QUEUE_TICKET_ENV] = queue_ticket
proc_kwargs = env_utils.get_detached_process_kwargs()
@@ -180,12 +222,18 @@ def mobile_run_task(
cwd=project_root,
**proc_kwargs,
)
transfer_kwargs: dict[str, Any] = {
"description": f"MCP task: {task_desc[:120]}",
"session_id": trace_id,
"ingress": "mcp",
}
if device_serial:
transfer_kwargs["device_id"] = device_serial
DeviceExecutionLock.transfer_reservation(
queue_ticket,
proc.pid,
description=f"MCP task: {task_desc[:120]}",
session_id=trace_id,
ingress="mcp",
**transfer_kwargs,
)
# 5. Record the PID of the spawned subprocess
@@ -201,6 +249,7 @@ def mobile_run_task(
response_dict: dict[str, Any] = {
"trace_id": trace_id,
"device_serial": device_serial or "auto-select",
"message": "Successfully started background task.",
"stdout_log": stdout_log_path,
"stderr_log": stderr_log_path,
+16
View File
@@ -43,6 +43,7 @@ def init_trace(
task_desc: str,
model: str,
conversation_id: str,
device_serial: str | None = None,
) -> dict[str, Any]:
"""Initializes the trace directory and creates the initial status.json file."""
trace_dir = get_trace_dir(trace_id)
@@ -54,6 +55,7 @@ def init_trace(
"model": model,
"conversation_id": conversation_id,
"status": "running",
"device_serial": device_serial,
"start_time": time.time(),
"end_time": None,
"error": None,
@@ -89,6 +91,7 @@ def update_trace_status(
status: str,
error: str | None = None,
result: Any | None = None,
device_serial: str | None = None,
) -> dict[str, Any] | None:
"""Updates specific fields of the status.json for a given trace_id."""
data = read_status(trace_id)
@@ -103,6 +106,19 @@ def update_trace_status(
data["error"] = error
if result is not None:
data["result"] = result
if device_serial is not None:
data["device_serial"] = device_serial
write_status(trace_id, data)
return data
def update_trace_device_serial(trace_id: str, device_serial: str) -> dict[str, Any] | None:
"""Updates the device_serial field of the status.json for a given trace_id."""
data = read_status(trace_id)
if not data:
return None
data["device_serial"] = device_serial
write_status(trace_id, data)
return data
+7 -7
View File
@@ -291,17 +291,17 @@ async def _run_ui_sequence(controller, recording_started: float) -> tuple[list[G
async def _analyze(analyzer: VideoAnalyzer, end_time: float, repeats: int) -> list[dict[str, Any]]:
queries = {
"timeline": (
"按时间顺序列出录像中的四个事实并给出可见证据和录像相对时间:"
"先完成 12+34=46;再完成 7×8=56;随后打开历史记录面板;最后关闭历史记录面板并回到显示 56 的主界面。"
"不要根据提示猜测,只报告视频中实际可见的内容。"
"List four facts from the recording in chronological order with visible evidence and relative timestamps: "
"First complete 12+34=46; then complete 7×8=56; subsequently open the history panel; finally close the history panel and return to the main screen showing 56. "
"Do not guess based on prompts, only report what is actually visible in the video."
),
"calculations": (
"只核验录像中实际输入的两道计算及其最终可见结果。必须明确写出完整表达式 12+34=46 和 7×8=56,"
"并按发生顺序报告;若任一项不可见就明确说缺失。"
"Only verify the two calculations actually entered in the recording and their final visible results. Must explicitly write the complete expressions 12+34=46 and 7×8=56, "
"and report them in order of occurrence; if any is not visible, clearly state that it is missing."
),
"history": (
"只核验历史记录面板的状态变化:它是否在完成两次计算后被打开、是否显示两次计算记录、"
"以及是否随后被关闭并返回计算器主界面。请明确使用“打开”和“关闭”描述两个转场。"
"Only verify state changes of the history panel: whether it was opened after completing both calculations, whether it displayed both calculation records, "
"and whether it was subsequently closed returning to the calculator main screen. Please explicitly use 'open' and 'close' to describe the two transitions."
),
}
results: list[dict[str, Any]] = []
+257
View File
@@ -0,0 +1,257 @@
"""Interactive Edge-Case Test Suite with Clear Step-by-Step Guidance.
Supports running each edge-case part independently or all together.
Usage:
python scripts/run_edge_case_part.py --part 1
python scripts/run_edge_case_part.py --part 2
python scripts/run_edge_case_part.py --part 3
python scripts/run_edge_case_part.py --part 4
python scripts/run_edge_case_part.py --part all
"""
import argparse
import asyncio
import os
import sys
import time
import requests
WORKSPACE_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if WORKSPACE_ROOT not in sys.path:
sys.path.insert(0, WORKSPACE_ROOT)
from artemis.interfaces.sdk.client import ArtemisClient
from artemis.runtime.device_lock import DeviceExecutionLock
from artemis.toolchain import ensure_toolchain_in_path
ensure_toolchain_in_path()
from artemis.runtime import device_pool
def _resolve_devices() -> tuple[str, str]:
all_devs = device_pool.list_devices()
real_dev = next((d for d in all_devs if not d.is_emulator and d.state == "device"), None)
emu_dev = next((d for d in all_devs if d.is_emulator and d.state == "device"), None)
phone = os.environ.get("ARTEMIS_PHONE_SERIAL") or (real_dev.serial if real_dev else "63191FDKX00062")
emu = os.environ.get("ARTEMIS_EMULATOR_SERIAL") or (emu_dev.serial if emu_dev else "emulator-5554")
return phone, emu
PHONE_SERIAL, EMULATOR_SERIAL = _resolve_devices()
API_STATUS_URL = "http://127.0.0.1:8000/api/status"
def query_api():
try:
r = requests.get(API_STATUS_URL, timeout=3).json()
return r.get("active_tasks", []), r.get("queue", [])
except Exception:
return [], []
def print_status_snapshot(label: str):
active, queue = query_api()
print(f"\n[{label}] Web Console Live Snapshot (/api/status):")
print(f" ⚡ Running Tasks ({len(active)}):")
for a in active:
dev = a.get("device_id") or a.get("device_serial")
print(f" - [Device: {dev}] {a.get('goal')} (PID: {a.get('pid')})")
print(f" ⏳ Pending Tasks ({len(queue)}):")
for q in queue:
dev = q.get("device_serial") or q.get("device_id")
print(f" - [Device: {dev}] {q.get('goal')} [status={q.get('status')}]")
print(f" 📊 Total Active Queue (Running + Pending) = {len(active) + len(queue)}\n")
async def run_part_1():
print("=" * 80)
print("[Part 1: Single Device Serial Queueing & Automatic Relay]")
print("=" * 80)
print("🎯 Goal: Launch 2 tasks on the same physical device; verify FIFO queueing, Web Active Queue count, and relay.")
print("📱 Target Device: Physical Pixel 11 Pro (63191FDKX00062)")
print("👀 Please observe:")
print(" 1. Web Console (http://127.0.0.1:8000): Active Queue becomes 2 (1 Running blue badge, 1 Pending yellow badge)")
print(" 2. Device Screen: Task 1A opens Settings first; upon completion, Task 1B automatically takes over to open Clock")
print(" 3. Web Console: After 1A finishes, 1B switches to Running, and queue eventually clears to zero")
print("-" * 80)
DeviceExecutionLock.cleanup_stale_locks()
client = ArtemisClient(device_serial=PHONE_SERIAL, concurrency_mode="per_device")
async def task_1a():
print("🚀 [Task 1A] Launching: Open Settings on physical device...")
res = await client.run("Use manage_app to launch Settings app and report task status completed", profile="flash")
print(f"✅ [Task 1A] Completed: status={res.status}")
return res
async def task_1b():
await asyncio.sleep(0.4)
print("🚀 [Task 1B] Launching: Open Clock on physical device -> should immediately enter queue...")
res = await client.run("Use manage_app to launch Clock app and report task status completed", profile="flash")
print(f"✅ [Task 1B] Completed: status={res.status}")
return res
t1 = asyncio.create_task(task_1a())
t2 = asyncio.create_task(task_1b())
await asyncio.sleep(1.2)
print_status_snapshot("Part 1 Running")
await asyncio.gather(t1, t2)
print("🎉 Part 1 Success: Task 1B automatically relayed and completed after Task 1A finished!\n")
async def run_part_2():
print("=" * 80)
print("[Part 2: Multi-Device True Parallel Execution]")
print("=" * 80)
print("🎯 Goal: Cross-device true concurrency; both devices run independently at the same time without blocking.")
print(f"📱 Target Device 1: Physical Pixel 11 Pro ({PHONE_SERIAL})")
print(f"💻 Target Device 2: Android Emulator ({EMULATOR_SERIAL})")
print("👀 Please observe:")
print(" 1. Dual screens: Physical device and emulator operate simultaneously without sequential waiting!")
print(" 2. Web Console (http://127.0.0.1:8000): Active Tasks shows 2 (both with blue Running badges)")
print(" 3. Queue count: Queue is 0, each device displays its own Device Serial")
print("-" * 80)
DeviceExecutionLock.cleanup_stale_locks()
client_phone = ArtemisClient(device_serial=PHONE_SERIAL, concurrency_mode="per_device")
client_emu = ArtemisClient(device_serial=EMULATOR_SERIAL, concurrency_mode="per_device")
start_t = time.time()
async def p_task():
print(f"🚀 [Phone Task] Launching: Open Settings on {PHONE_SERIAL}...")
res = await client_phone.run("Use manage_app to launch Settings app and report task status completed", profile="flash")
print(f"✅ [Phone Task] Finished in {time.time() - start_t:.2f}s")
return res
async def e_task():
print(f"🚀 [Emulator Task] Launching: Open Settings on {EMULATOR_SERIAL}...")
res = await client_emu.run("Use manage_app to launch Settings app and report task status completed", profile="flash")
print(f"✅ [Emulator Task] Finished in {time.time() - start_t:.2f}s")
return res
tp = asyncio.create_task(p_task())
te = asyncio.create_task(e_task())
await asyncio.sleep(1.2)
print_status_snapshot("Part 2 Running (Multi-Device Concurrency)")
await asyncio.gather(tp, te)
print(f"🎉 Part 2 Success: Both devices completed tasks concurrently in {time.time() - start_t:.2f}s!\n")
async def run_part_3():
print("=" * 80)
print("[Part 3: Global Concurrency Limit Across Devices]")
print("=" * 80)
print("🎯 Goal: Configure global concurrency mode (global, limit 1); even targeting different devices, tasks must queue globally.")
print(f"📱 Device 1: Physical Pixel 11 Pro ({PHONE_SERIAL})")
print(f"💻 Device 2: Android Emulator ({EMULATOR_SERIAL})")
print("👀 Please observe:")
print(" 1. Emulator screen: Even though emulator is completely idle, it waits until the phone task completes!")
print(" 2. Web Console: Phone task is Running (blue badge), emulator task is Pending (yellow badge)!")
print(" 3. Once phone finishes, emulator instantly activates and begins execution!")
print("-" * 80)
DeviceExecutionLock.cleanup_stale_locks()
client_phone_g = ArtemisClient(device_serial=PHONE_SERIAL, concurrency_mode="global")
client_emu_g = ArtemisClient(device_serial=EMULATOR_SERIAL, concurrency_mode="global")
async def task_3a():
print(f"🚀 [Task 3A - Phone] Global mode starting: Executing on {PHONE_SERIAL}...")
res = await client_phone_g.run("Use manage_app to launch Settings app and report task status completed", profile="flash")
print(f"✅ [Task 3A - Phone] Finished")
return res
async def task_3b():
await asyncio.sleep(0.4)
print(f"🚀 [Task 3B - Emulator] Global mode starting: Targeting {EMULATOR_SERIAL} (should queue due to global limit)...")
res = await client_emu_g.run("Use manage_app to launch Settings app and report task status completed", profile="flash")
print(f"✅ [Task 3B - Emulator] Finished")
return res
t3a = asyncio.create_task(task_3a())
t3b = asyncio.create_task(task_3b())
await asyncio.sleep(1.2)
print_status_snapshot("Part 3 Running (Global Limit Cross-Device Queueing)")
await asyncio.gather(t3a, t3b)
print("🎉 Part 3 Success: Global mutex accurately queued and relayed tasks across physical phone and emulator!\n")
async def run_part_4():
print("=" * 80)
print("[Part 4: Multi-Device Asymmetric Mixed Queueing]")
print("=" * 80)
print("🎯 Goal: Assign 2 tasks to physical phone, 1 task to emulator.")
print(" Expected: Phone 4A and Emulator 4C run concurrently; Phone 4B queues in dedicated phone queue; 4B relays immediately on phone after 4A.")
print("👀 Please observe:")
print(" 1. Web Console: Active Tasks = 2 (Phone 4A + Emulator 4C), Queue = 1 (Phone 4B), Total = 3!")
print(" 2. Screens: Phone and emulator operate concurrently first; Phone 4B executes on phone immediately after 4A finishes!")
print("-" * 80)
DeviceExecutionLock.cleanup_stale_locks()
client_phone = ArtemisClient(device_serial=PHONE_SERIAL, concurrency_mode="per_device")
client_emu = ArtemisClient(device_serial=EMULATOR_SERIAL, concurrency_mode="per_device")
async def task_4a():
print(f"🚀 [Task 4A - Phone] Started...")
return await client_phone.run("Use manage_app to launch Settings app and report task status completed", profile="flash")
async def task_4b():
await asyncio.sleep(0.4)
print(f"🚀 [Task 4B - Phone] Started (Queued behind 4A)...")
return await client_phone.run("Use manage_app to launch Clock app and report task status completed", profile="flash")
async def task_4c():
print(f"🚀 [Task 4C - Emulator] Started concurrently with 4A...")
return await client_emu.run("Use manage_app to launch Settings app and report task status completed", profile="flash")
t4a = asyncio.create_task(task_4a())
t4b = asyncio.create_task(task_4b())
t4c = asyncio.create_task(task_4c())
await asyncio.sleep(1.2)
print_status_snapshot("Part 4 Running (Mixed Queueing)")
await asyncio.gather(t4a, t4b, t4c)
print("🎉 Part 4 Success: Cross-device concurrency and single-device queueing work together seamlessly!\n")
async def main():
global PHONE_SERIAL, EMULATOR_SERIAL
parser = argparse.ArgumentParser(description="Artemis Concurrency & Queue Edge-Cases Runner")
parser.add_argument("--part", choices=["1", "2", "3", "4", "all"], default="1", help="Which part to run (1, 2, 3, 4, or all)")
parser.add_argument("--phone", default=None, help="Target physical phone serial number")
parser.add_argument("--emulator", default=None, help="Target Android emulator serial number")
args = parser.parse_args()
if args.phone:
PHONE_SERIAL = args.phone
if args.emulator:
EMULATOR_SERIAL = args.emulator
if args.part == "1":
await run_part_1()
elif args.part == "2":
await run_part_2()
elif args.part == "3":
await run_part_3()
elif args.part == "4":
await run_part_4()
elif args.part == "all":
await run_part_1()
await asyncio.sleep(1)
await run_part_2()
await asyncio.sleep(1)
await run_part_3()
await asyncio.sleep(1)
await run_part_4()
if __name__ == "__main__":
asyncio.run(main())
+77 -7
View File
@@ -73,12 +73,47 @@ def main() -> None:
)
parser.add_argument("-p", "--port", type=int, default=8000, help="Port to run UI on")
parser.add_argument("-H", "--host", type=str, default="0.0.0.0", help="Host address to bind")
parser.add_argument("--no-open", action="store_true", help="Do not automatically open browser")
parser.add_argument(
"--reload", action="store_true", help="Enable uvicorn auto-reload for development"
"-r",
"--restart",
action="store_true",
help="Restart the server by terminating any existing instance on the port first",
)
parser.add_argument(
"-s",
"--stop",
action="store_true",
help="Stop running Artemis server on the specified port",
)
parser.add_argument(
"--status",
action="store_true",
help="Display status of running Artemis server on the specified port",
)
args, unknown = parser.parse_known_args()
# Ensure project root is on sys.path for lifecycle utility imports
if str(ROOT_DIR) not in sys.path:
sys.path.insert(0, str(ROOT_DIR))
from artemis.runtime.server_lifecycle import get_server_status, stop_server
if args.status:
st = get_server_status(args.port)
if st["running"]:
pid_info = f" (PID: {', '.join(map(str, st['pids']))})" if st["pids"] else ""
print(f"\033[1;32m● Artemis Server is RUNNING on port {args.port}{pid_info}\033[0m")
print(f" URL: {st['url']}")
else:
print(f"\033[1;30m○ Artemis Server is STOPPED (Port {args.port} is free)\033[0m")
return
if args.stop:
print(f"\033[1;33m🛑 Stopping Artemis server on port {args.port}...\033[0m")
ok, msg, pids = stop_server(args.port)
print(f"\033[1;32m✓ {msg}\033[0m")
return
ui_url = f"http://localhost:{args.port}"
admin_url = f"http://localhost:{args.port}/admin"
@@ -86,15 +121,50 @@ def main() -> None:
print("\033[1;36m ✨ Artemis Autonomous Mobile Agent UI \033[0m")
print("\033[1;36m" + "=" * 56 + "\033[0m\n")
# Check if restart requested
if args.restart:
print(f"\033[1;33m🔄 Restart requested. Recycling port {args.port}...\033[0m")
ok, msg, pids = stop_server(args.port)
if pids:
print(f"\033[1;32m✓ {msg}\033[0m\n")
else:
print(f"Port {args.port} is clear.\n")
# Check if server is already running
if is_port_in_use(args.port):
elif is_port_in_use(args.port):
if is_artemis_ui_responsive(ui_url):
print(f"\033[1;32m✓ Artemis UI is already running at:\033[0m \033[1;36m{ui_url}\033[0m")
print(f"\033[1;35m🛠️ Admin Console:\033[0m \033[1;36m{admin_url}\033[0m\n")
if not args.no_open:
print("🌐 Opening browser...")
webbrowser.open(ui_url)
return
# In interactive terminal, allow user to restart or open browser
if sys.stdin.isatty():
print("Server is active in another session/terminal.")
print(" [1] Open browser (default)")
print(" [2] Restart server (stop existing and start here)")
print(" [3] Stop server")
try:
choice = input("Select [1-3, default 1]: ").strip()
except (KeyboardInterrupt, EOFError):
choice = "1"
if choice == "2":
print(f"\n\033[1;33m🔄 Restarting Artemis server on port {args.port}...\033[0m")
stop_server(args.port)
elif choice == "3":
print(f"\n\033[1;33m🛑 Stopping Artemis server on port {args.port}...\033[0m")
ok, msg, _ = stop_server(args.port)
print(f"\033[1;32m✓ {msg}\033[0m")
return
else:
if not args.no_open:
print("🌐 Opening browser...")
webbrowser.open(ui_url)
return
else:
if not args.no_open:
print("🌐 Opening browser...")
webbrowser.open(ui_url)
return
else:
print(f"\033[1;33m⚠️ Port {args.port} is in use by another process.\033[0m")
print("Attempting to connect or start on specified port...")
+258
View File
@@ -0,0 +1,258 @@
"""Comprehensive Edge-Case Test Suite for Artemis Multi-Device Concurrency & Unified Queue.
Covers:
1. Single Device FIFO Serial Queueing & Relay
2. Multi-Device True Parallel Execution
3. Global Concurrency Serialization across Distinct Devices
4. Mixed Multi-Device Queueing
"""
import asyncio
import os
import sys
import time
import requests
# Ensure workspace in path
WORKSPACE_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if WORKSPACE_ROOT not in sys.path:
sys.path.insert(0, WORKSPACE_ROOT)
from artemis.interfaces.sdk.client import ArtemisClient
from artemis.runtime.device_lock import DeviceExecutionLock
from artemis.toolchain import ensure_toolchain_in_path
ensure_toolchain_in_path()
from artemis.runtime import device_pool
def _resolve_devices() -> tuple[str, str]:
all_devs = device_pool.list_devices()
real_dev = next((d for d in all_devs if not d.is_emulator and d.state == "device"), None)
emu_dev = next((d for d in all_devs if d.is_emulator and d.state == "device"), None)
phone = os.environ.get("ARTEMIS_PHONE_SERIAL") or (real_dev.serial if real_dev else "63191FDKX00062")
emu = os.environ.get("ARTEMIS_EMULATOR_SERIAL") or (emu_dev.serial if emu_dev else "emulator-5554")
return phone, emu
PHONE_SERIAL, EMULATOR_SERIAL = _resolve_devices()
API_STATUS_URL = "http://127.0.0.1:8000/api/status"
def query_api_status():
try:
res = requests.get(API_STATUS_URL, timeout=3).json()
active = res.get("active_tasks", [])
queue = res.get("queue", [])
return active, queue
except Exception as e:
return [], []
def print_status_snapshot(stage_name: str):
active, queue = query_api_status()
print(f"\n[{stage_name}] Live Snapshot from Web Console (/api/status):")
print(f" ⚡ Running Tasks ({len(active)}):")
for a in active:
print(f" - Device: {a.get('device_id')} | Goal: {a.get('goal')} (PID: {a.get('pid')})")
print(f" ⏳ Queued Tasks ({len(queue)}):")
for q in queue:
dev = q.get('device_serial') or q.get('device_id')
print(f" - Device: {dev} | Goal: {q.get('goal')} [status={q.get('status')}]")
print(f" 📊 Total in Active Queue (Running + Pending) = {len(active) + len(queue)}\n")
async def stage_1_single_device_queueing():
print("\n" + "=" * 80)
print("STAGE 1: Single Device FIFO Queueing & Relay")
print(f"Target Device: {PHONE_SERIAL} (Pixel 11 Pro)")
print("=" * 80)
client_phone = ArtemisClient(device_serial=PHONE_SERIAL, concurrency_mode="per_device")
t1_started = False
t2_queued = False
async def task_1a():
print(f"🚀 [Task 1A] Launching on {PHONE_SERIAL}: Press HOME and open Settings...")
res = await client_phone.run("Press HOME key and open Settings", profile="flash")
print(f"✅ [Task 1A] Finished with status: {res.status}")
return res
async def task_1b():
# Small delay so 1A is definitely acquired first
await asyncio.sleep(0.4)
print(f"🚀 [Task 1B] Launching on {PHONE_SERIAL}: Press HOME and open Clock (Should queue!)...")
res = await client_phone.run("Press HOME key and open Clock", profile="flash")
print(f"✅ [Task 1B] Finished with status: {res.status}")
return res
# Run both simultaneously
task_a_coro = asyncio.create_task(task_1a())
task_b_coro = asyncio.create_task(task_1b())
# Wait 1.5s and inspect status while 1A is running and 1B is queued
await asyncio.sleep(1.2)
print_status_snapshot("STAGE 1 In-Progress")
active, queue = query_api_status()
assert len(active) >= 1, "Expected Task 1A to be actively running!"
# 1B should be in the queue or 1A just finished
print(f"✓ Verified: Task 1A is running on {PHONE_SERIAL}")
res_a, res_b = await asyncio.gather(task_a_coro, task_b_coro)
print("✓ Stage 1 Complete: Both tasks executed in order and completed successfully!")
async def stage_2_multi_device_parallel():
print("\n" + "=" * 80)
print("STAGE 2: Multi-Device True Parallel Execution")
print(f"Devices: {PHONE_SERIAL} (Pixel 11 Pro) AND {EMULATOR_SERIAL} (Emulator)")
print("Concurrency Mode: per_device (Should run simultaneously!)")
print("=" * 80)
client_phone = ArtemisClient(device_serial=PHONE_SERIAL, concurrency_mode="per_device")
client_emu = ArtemisClient(device_serial=EMULATOR_SERIAL, concurrency_mode="per_device")
start_time = time.time()
async def task_phone():
print(f"📱 [Phone Task] Running on {PHONE_SERIAL}: Open Settings...")
res = await client_phone.run("Press HOME key and open Settings", profile="flash")
print(f"✅ [Phone Task] Completed in {time.time() - start_time:.2f}s")
return res
async def task_emu():
print(f"💻 [Emulator Task] Running on {EMULATOR_SERIAL}: Open Settings...")
res = await client_emu.run("Press HOME key and open Settings", profile="flash")
print(f"✅ [Emulator Task] Completed in {time.time() - start_time:.2f}s")
return res
p_task = asyncio.create_task(task_phone())
e_task = asyncio.create_task(task_emu())
# Check during run
await asyncio.sleep(1.2)
print_status_snapshot("STAGE 2 Parallel In-Progress")
active, queue = query_api_status()
print(f"Active devices count: {len(active)}")
active_devices = {a.get("device_id") for a in active}
print(f"Active devices: {active_devices}")
await asyncio.gather(p_task, e_task)
total_duration = time.time() - start_time
print(f"✓ Stage 2 Complete: Both devices completed parallel tasks in {total_duration:.2f}s!")
async def stage_3_global_concurrency_mode():
print("\n" + "=" * 80)
print("STAGE 3: Global Concurrency Mode Serialization")
print("Concurrency Mode: global (max 1 task system-wide even across different devices)")
print("=" * 80)
client_phone_global = ArtemisClient(device_serial=PHONE_SERIAL, concurrency_mode="global")
client_emu_global = ArtemisClient(device_serial=EMULATOR_SERIAL, concurrency_mode="global")
async def task_phone_g():
print(f"📱 [Task 3A - Phone] Acquiring global lock on {PHONE_SERIAL}...")
res = await client_phone_global.run("Press HOME key and open Settings", profile="flash")
print(f"✅ [Task 3A - Phone] Finished.")
return res
async def task_emu_g():
await asyncio.sleep(0.4)
print(f"💻 [Task 3B - Emulator] Requesting run on {EMULATOR_SERIAL} under global mode (Should queue!)...")
res = await client_emu_global.run("Press HOME key and open Settings", profile="flash")
print(f"✅ [Task 3B - Emulator] Finished.")
return res
p_task = asyncio.create_task(task_phone_g())
e_task = asyncio.create_task(task_emu_g())
await asyncio.sleep(1.2)
print_status_snapshot("STAGE 3 Global Mode In-Progress")
active, queue = query_api_status()
print(f"Active count: {len(active)}, Queue count: {len(queue)}")
await asyncio.gather(p_task, e_task)
print("✓ Stage 3 Complete: Global lock correctly serialized execution across physical phone and emulator!")
async def stage_4_mixed_queue_and_parallel():
print("\n" + "=" * 80)
print("STAGE 4: Mixed Multi-Device Queueing")
print("Scenario: Phone gets 2 tasks (4A, 4B), Emulator gets 1 task (4C).")
print("Expectation: 4A (Phone) and 4C (Emulator) run concurrently; 4B waits for Phone.")
print("=" * 80)
client_phone = ArtemisClient(device_serial=PHONE_SERIAL, concurrency_mode="per_device")
client_emu = ArtemisClient(device_serial=EMULATOR_SERIAL, concurrency_mode="per_device")
async def task_4a():
print(f"📱 [Task 4A - Phone] Running...")
return await client_phone.run("Press HOME key and open Settings", profile="flash")
async def task_4b():
await asyncio.sleep(0.4)
print(f"📱 [Task 4B - Phone] Queued behind 4A...")
return await client_phone.run("Press HOME key and open Clock", profile="flash")
async def task_4c():
print(f"💻 [Task 4C - Emulator] Running in parallel with 4A...")
return await client_emu.run("Press HOME key and open Settings", profile="flash")
t4a = asyncio.create_task(task_4a())
t4b = asyncio.create_task(task_4b())
t4c = asyncio.create_task(task_4c())
await asyncio.sleep(1.2)
print_status_snapshot("STAGE 4 Mixed Multi-Device In-Progress")
await asyncio.gather(t4a, t4b, t4c)
print("✓ Stage 4 Complete: 4A and 4C ran in parallel, 4B queued and picked up phone relay!")
async def main():
import argparse
global PHONE_SERIAL, EMULATOR_SERIAL
parser = argparse.ArgumentParser(description="ARTEMIS All Edge-Cases Comprehensive Test Suite")
parser.add_argument("--phone", default=None, help="Target physical phone serial number")
parser.add_argument("--emulator", default=None, help="Target Android emulator serial number")
args = parser.parse_args()
if args.phone:
PHONE_SERIAL = args.phone
if args.emulator:
EMULATOR_SERIAL = args.emulator
print("\n" + "#" * 80)
print("# ARTEMIS ALL EDGE-CASES COMPREHENSIVE CONCURRENCY & QUEUE TEST SUITE")
print(f"# Devices: Real Phone ({PHONE_SERIAL}) + Android Emulator ({EMULATOR_SERIAL})")
print("# Web Console API: http://127.0.0.1:8000/api/status")
print("#" * 80)
# Initial cleanup
DeviceExecutionLock.cleanup_stale_locks()
print_status_snapshot("Initial System State")
# Run all 4 stages
await stage_1_single_device_queueing()
await asyncio.sleep(1)
await stage_2_multi_device_parallel()
await asyncio.sleep(1)
await stage_3_global_concurrency_mode()
await asyncio.sleep(1)
await stage_4_mixed_queue_and_parallel()
print("\n" + "=" * 80)
print("🎉 ALL EDGE-CASE STAGES COMPLETED SUCCESSFULLY!")
print("=" * 80)
print_status_snapshot("Final System State")
if __name__ == "__main__":
asyncio.run(main())
+312
View File
@@ -0,0 +1,312 @@
#!/usr/bin/env python3
# Copyright 2026 Google LLC
"""Live experiment demonstrating Artemis concurrency modes and device targeting.
Covers:
1. Single-device queueing and relay (FIFO mutual exclusion on the same device).
2. Multi-device parallel execution (per_device concurrency mode across real device & emulator).
3. Cross-device global serialization (global concurrency mode restricting all devices to 1 task).
4. Full parameter coverage (device_serial, set_device, Task objects, list_devices, get_idle_devices).
"""
import asyncio
import os
import sys
import time
from datetime import datetime
# Add project root to sys.path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from artemis import ArtemisClient, ConcurrencyMode, Task
from artemis.runtime import DeviceBusyError, device_pool
def print_header(title: str):
print("\n" + "=" * 76)
print(f" {title}")
print("=" * 76)
def now_str() -> str:
return datetime.now().strftime("%H:%M:%S.%f")[:-3]
async def run_experiment_1_single_device_relay(real_serial: str):
"""Experiment 1: Single device FIFO queueing & relay."""
print_header("[Experiment 1] Single-Device Task Queueing & Relay (FIFO Relay on Single Device)")
print(f"Target Device: {real_serial} (Physical Pixel 11 Pro)")
client = ArtemisClient(device_serial=real_serial, default_profile="flash", concurrency_mode="per_device")
print(f"Client Init: device_serial='{client.device_serial}', concurrency_mode='{client.concurrency_mode}'")
task_1_launched = 0.0
task_1_start = 0.0
task_1_end = 0.0
task_2_launched = 0.0
task_2_start = 0.0
task_2_end = 0.0
async def execute_task_1():
nonlocal task_1_launched, task_1_start, task_1_end
task_1_launched = time.time()
print(f"[{now_str()}] ▶ [Task 1] Submitted for scheduling (target device: {real_serial})")
task_1_start = time.time()
res = await client.run(
"Open Settings and check battery level",
device_serial=real_serial,
max_turns=3,
)
task_1_end = time.time()
print(
f"[{now_str()}] ◀ [Task 1] Execution completed! Status={res.status}, "
f"Trace={res.trace_id[:8]}..., Elapsed={task_1_end - task_1_start:.3f}s"
)
return res
async def execute_task_2():
nonlocal task_2_launched, task_2_start, task_2_end
task_2_launched = time.time()
print(f"[{now_str()}] ▶ [Task 2] Submitted concurrently (detects device occupied by Task 1, entering FIFO queue...)")
task_2_start = time.time()
res = await client.run(
"Return to home screen and open Clock",
device_serial=real_serial,
max_turns=3,
)
task_2_end = time.time()
print(
f"[{now_str()}] ◀ [Task 2] Relayed execution completed! Status={res.status}, "
f"Trace={res.trace_id[:8]}..., Elapsed={task_2_end - task_2_start:.3f}s"
)
return res
# Launch Task 1 first, then Task 2 40ms later while Task 1 is holding the lock
t_coro_1 = asyncio.create_task(execute_task_1())
await asyncio.sleep(0.04)
t_coro_2 = asyncio.create_task(execute_task_2())
res1, res2 = await asyncio.gather(t_coro_1, t_coro_2)
print("\n[Timing & Relay Analysis]:")
print(f" Task 1 runtime interval: [{task_1_start:.3f}s ~ {task_1_end:.3f}s] (elapsed {task_1_end - task_1_start:.3f}s)")
print(f" Task 2 submission time: {task_2_launched:.3f}s")
print(f" Task 2 actual start: {task_2_start:.3f}s (waited for Task 1 lock release)")
print(f" Task 2 end time: {task_2_end:.3f}s (elapsed {task_2_end - task_2_start:.3f}s)")
assert task_2_end > task_1_end, "Task 2 did not complete after Task 1!"
assert task_2_end - task_1_start >= (task_1_end - task_1_start) + 0.1, "Tasks failed to execute in sequential queue!"
print(" ✅ Verification successful: Task 2 queued properly and relayed immediately after Task 1 released the lock!")
async def run_experiment_2_multi_device_parallel(real_serial: str, emu_serial: str):
"""Experiment 2: Multi-device parallel execution under per_device concurrency."""
print_header("[Experiment 2] Multi-Device Parallel Execution Test")
print(f"Device A (Physical): {real_serial}")
print(f"Device B (Emulator): {emu_serial}")
client_a = ArtemisClient(device_serial=real_serial, default_profile="flash", concurrency_mode="per_device")
client_b = ArtemisClient(device_serial=emu_serial, default_profile="flash", concurrency_mode="per_device")
a_start, a_end = 0.0, 0.0
b_start, b_end = 0.0, 0.0
async def run_on_device_a():
nonlocal a_start, a_end
print(f"[{now_str()}] ▶ [Device A Task] Starting on {real_serial}")
a_start = time.time()
res = await client_a.run("Test operation A on physical device", max_turns=3)
a_end = time.time()
print(f"[{now_str()}] ◀ [Device A Task] Completed! Elapsed: {a_end - a_start:.3f}s, Device: {res.device_id}")
return res
async def run_on_device_b():
nonlocal b_start, b_end
print(f"[{now_str()}] ▶ [Device B Task] Starting on {emu_serial}")
b_start = time.time()
res = await client_b.run("Test operation B on emulator", max_turns=3)
b_end = time.time()
print(f"[{now_str()}] ◀ [Device B Task] Completed! Elapsed: {b_end - b_start:.3f}s, Device: {res.device_id}")
return res
t_start = time.time()
task_a = asyncio.create_task(run_on_device_a())
task_b = asyncio.create_task(run_on_device_b())
res_a, res_b = await asyncio.gather(task_a, task_b)
t_total = time.time() - t_start
print("\n[Dual-Device Concurrency Analysis]:")
print(f" Device A execution interval: {a_start:.3f}s ~ {a_end:.3f}s (elapsed {a_end - a_start:.3f}s)")
print(f" Device B execution interval: {b_start:.3f}s ~ {b_end:.3f}s (elapsed {b_end - b_start:.3f}s)")
print(f" Total concurrent duration: {t_total:.3f}s (sum of sequential durations: {(a_end - a_start) + (b_end - b_start):.3f}s)")
# Overlap verification
overlap = min(a_end, b_end) - max(a_start, b_start)
print(f" Parallel overlap duration: {overlap:.3f}s")
assert overlap > 0.0, "Dual devices failed to run concurrently with overlap!"
assert t_total < ((a_end - a_start) + (b_end - b_start) * 0.95), "Total duration should be less than sum of both tasks!"
print(" ✅ Verification successful: Tasks on both devices ran concurrently without blocking!")
async def run_experiment_3_global_concurrency_serialization(real_serial: str, emu_serial: str):
"""Experiment 3: Global concurrency serialization across devices."""
print_header("[Experiment 3] Global Concurrency Mode Test")
print(f"Mode description: concurrency_mode='global', entire system allows only 1 task across all devices.")
client_a = ArtemisClient(device_serial=real_serial, default_profile="flash", concurrency_mode="global")
client_b = ArtemisClient(device_serial=emu_serial, default_profile="flash", concurrency_mode="global")
# 3.1 Test non-blocking mode rejection
print("\n[Subtest 3.1: Non-blocking rejection limit]")
from artemis.runtime import DeviceExecutionLock
lock_a = DeviceExecutionLock(real_serial, "Placeholder test lock", concurrency_mode="global")
await asyncio.to_thread(lock_a.acquire)
try:
print(f"[{now_str()}] Device A ({real_serial}) holds the global lock...")
try:
print(f"[{now_str()}] Device B ({emu_serial}) attempts run with blocking=False...")
await client_b.run("Emulator non-blocking task", blocking=False)
print(" ❌ Exception: Was not intercepted by global lock!")
except DeviceBusyError as exc:
print(f" ✅ Correctly intercepted with DeviceBusyError: {exc}")
finally:
await asyncio.to_thread(lock_a.release)
# 3.2 Test cross-device queueing and relay
print("\n[Subtest 3.2: Cross-device global queueing & relay]")
t1_submitted, t1_end = 0.0, 0.0
t2_submitted, t2_end = 0.0, 0.0
async def run_global_task_1():
nonlocal t1_submitted, t1_end
t1_submitted = time.time()
print(f"[{now_str()}] ▶ [Global Task 1] Started on physical device ({real_serial})...")
res = await client_a.run("Global task 1", max_turns=3)
t1_end = time.time()
print(f"[{now_str()}] ◀ [Global Task 1] Completed! Elapsed: {t1_end - t1_submitted:.3f}s")
return res
async def run_global_task_2():
nonlocal t2_submitted, t2_end
# Submit task 2 shortly after task 1
await asyncio.sleep(0.04)
t2_submitted = time.time()
print(f"[{now_str()}] ▶ [Global Task 2] Submitted to emulator ({emu_serial}), entering queue due to global limit...")
res = await client_b.run("Global task 2", max_turns=3)
t2_end = time.time()
print(f"[{now_str()}] ◀ [Global Task 2] Relayed completion! Total time (including queue): {t2_end - t2_submitted:.3f}s")
return res
res1, res2 = await asyncio.gather(run_global_task_1(), run_global_task_2())
print(f" Global Task 1 interval: {t1_submitted:.3f}s ~ {t1_end:.3f}s (elapsed {t1_end - t1_submitted:.3f}s)")
print(f" Global Task 2 submit time: {t2_submitted:.3f}s")
print(f" Global Task 2 end time: {t2_end:.3f}s (waited for Task 1 completion before executing)")
assert t2_end > t1_end, "Global Task 2 did not complete after Global Task 1!"
assert (t2_end - t1_end) >= 0.08, "Global Task 2 did not execute sequentially after Task 1 released global lock!"
print(" ✅ Verification successful: Under global mode, strictly 1 task executed sequentially across different devices!")
async def run_experiment_4_parameters_and_utilities(real_serial: str, emu_serial: str):
"""Experiment 4: Comprehensive SDK parameter and utility testing."""
print_header("[Experiment 4] SDK Parameter & Utility Comprehensive Test")
client = ArtemisClient()
print(f"1. Default idle device selection: client.device_serial = '{client.device_serial}'")
# Chain switch device
client.set_device(real_serial)
print(f"2. Chain switch to physical device: client.set_device('{real_serial}') -> '{client.device_serial}'")
assert client.device_serial == real_serial
# Attribute switch
client.device_serial = emu_serial
print(f"3. Property assignment to emulator: client.device_serial = '{emu_serial}' -> '{client.device_id}'")
assert client.device_id == emu_serial
# Dynamically change concurrency mode
client.set_concurrency_mode(ConcurrencyMode.GLOBAL)
print(f"4. Chain change concurrency mode: client.concurrency_mode = '{client.concurrency_mode}'")
assert client.concurrency_mode == "global"
client.concurrency_mode = "per_device"
# Query all available devices
print("5. client.list_devices() status:")
devices = client.list_devices()
for d in devices:
print(f" • [{d.serial}] State: {d.state}, Model: {d.model}, IsEmulator: {d.is_emulator}, Busy: {d.is_busy}")
# Query idle devices
idle = client.get_idle_devices()
print(f"6. client.get_idle_devices(): {[d.serial for d in idle]}")
# Use Task structured object with run_task
print("7. Calling client.run_task() with structured Task entity:")
task_obj = Task(
goal="Verify physical device call via Task object",
profile="flash",
device_serial=real_serial,
concurrency_mode="per_device",
max_turns=3,
)
result = await client.run_task(task_obj)
print(f" Task result: Status={result.status}, Device={result.device_id}, Trace={result.trace_id[:8]}...")
assert result.device_id == real_serial
# Use stream_run for live event stream
print("8. Using client.stream_run() for live event stream:")
events_received = 0
async for ev in client.stream_run(
"Stream operation test",
device_serial=emu_serial,
profile="flash",
max_turns=3,
):
events_received += 1
print(f" • [{ev.event_type}] payload={ev.payload}")
assert events_received >= 2
print(f" ✅ Stream events captured successfully! Captured {events_received} events.")
async def main():
# Detect devices
devices = device_pool.list_devices()
print("=" * 76)
print("[ARTEMIS Python SDK Concurrency & Device Targeting Experiment]")
print(f"Detected connected devices count: {len(devices)}")
for d in devices:
print(f" • Serial: {d.serial} | Model: {d.model} | State: {d.state} | IsEmulator: {d.is_emulator}")
print("=" * 76)
import argparse
parser = argparse.ArgumentParser(description="Live experiment demonstrating Artemis concurrency modes and device targeting.")
parser.add_argument("--phone", default=os.environ.get("ARTEMIS_PHONE_SERIAL"), help="Target physical phone serial number")
parser.add_argument("--emulator", default=os.environ.get("ARTEMIS_EMULATOR_SERIAL"), help="Target Android emulator serial number")
args, _ = parser.parse_known_args()
# Auto-detect connected devices if not explicitly provided
real_dev = next((d for d in devices if not d.is_emulator and d.state == "device"), None)
emu_dev = next((d for d in devices if d.is_emulator and d.state == "device"), None)
real_serial = args.phone or (real_dev.serial if real_dev else "63191FDKX00062")
emu_serial = args.emulator or (emu_dev.serial if emu_dev else "emulator-5554")
# Verify both devices are available
serials = [d.serial for d in devices]
if real_serial not in serials or emu_serial not in serials:
print(f"Error: Both physical device '{real_serial}' and emulator '{emu_serial}' must be connected for full experiment.")
print(f"Currently online devices: {serials}")
return
# Run experiments in sequence
await run_experiment_1_single_device_relay(real_serial)
await run_experiment_2_multi_device_parallel(real_serial, emu_serial)
await run_experiment_3_global_concurrency_serialization(real_serial, emu_serial)
await run_experiment_4_parameters_and_utilities(real_serial, emu_serial)
print_header("🎉 All experiments completed successfully! All concurrency and device targeting verified!")
if __name__ == "__main__":
asyncio.run(main())
@@ -55,3 +55,44 @@ async def test_list_sessions_uses_batched_history_metadata(monkeypatch):
repo.get_video_recording_for_session.assert_not_called()
repo.get_llm_traces_for_profile.assert_not_called()
repo.get_agent_trace_names.assert_not_called()
@pytest.mark.asyncio
async def test_list_sessions_extracts_device_serial(monkeypatch):
repo = MagicMock()
repo.get_all_sessions.return_value = [
{
"session_id": "session-dev-1",
"status": "completed",
"start_time": 1.0,
"device_info": '{"device_id": "63191FDKX00062", "profile": "flash"}',
},
{
"session_id": "session-dev-2",
"status": "completed",
"start_time": 2.0,
"device_info": None,
},
]
repo.get_video_recordings_map.return_value = {}
repo.get_latest_video_recordings_map.return_value = {
"session-dev-2": {"status": "ready", "device_id": "emulator-5554"},
}
repo.get_llm_traces_for_profiles_map.return_value = {}
repo.get_agent_trace_names_map.return_value = {}
monkeypatch.setattr(sessions_router, "session_repo", repo, raising=False)
monkeypatch.setattr(
sessions_router.media_service, "build_video_index", MagicMock(return_value={})
)
monkeypatch.setattr(
sessions_router.media_service, "resolve_video_url", MagicMock(return_value=None)
)
result = await sessions_router.list_sessions()
assert len(result) == 2
assert result[0]["device_serial"] == "63191FDKX00062"
assert result[0]["device_id"] == "63191FDKX00062"
assert result[1]["device_serial"] == "emulator-5554"
assert result[1]["device_id"] == "emulator-5554"
@@ -180,8 +180,8 @@ async def test_queue_worker_execution_lifecycle():
assert res["enqueued_count"] == 2
# Give event loop time for queue_worker to execute both tasks
for _ in range(30):
if len(executed_goals) == 2:
for _ in range(40):
if len(executed_goals) == 2 and len(state.queue_items) == 0:
break
await asyncio.sleep(0.05)
@@ -234,6 +234,7 @@ async def test_queue_worker_cmd_construction():
cmd = executed_cmds[0]
assert "--enable-outputter" in cmd
assert "true" not in cmd
assert "--session-id" in cmd
assert "--output-description" in cmd
assert "Final summary" in cmd
assert "--locked-app" in cmd
@@ -265,14 +266,16 @@ async def test_queue_worker_cmd_construction():
@pytest.mark.asyncio
async def test_forward_worker_output_preserves_split_utf8(capsys):
stream = asyncio.StreamReader()
encoded = "worker 输出正常\n".encode()
stream.feed_data(encoded[:9])
stream.feed_data(encoded[9:])
# "worker output: \U0001f600\n" contains a 4-byte UTF-8 emoji (b'\xf0\x9f\x98\x80') starting at byte 15.
# Splitting at index 17 cuts across the multi-byte sequence to verify partial UTF-8 buffering.
encoded = "worker output: \U0001f600\n".encode("utf-8")
stream.feed_data(encoded[:17])
stream.feed_data(encoded[17:])
stream.feed_eof()
await TaskQueueService._forward_worker_output(stream)
assert capsys.readouterr().out == "worker 输出正常\n"
assert capsys.readouterr().out == "worker output: \U0001f600\n"
@pytest.mark.skipif(sys.platform != "win32", reason="Windows console isolation only")
@@ -539,7 +542,7 @@ async def test_immediate_cancel_ignores_stale_ipc_and_runs_next_task():
await task_queue_service.enqueue_tasks(["Task 2"])
for _ in range(40):
if "Task 2" in executed_goals:
if "Task 2" in executed_goals and len(state.queue_items) == 0:
break
await asyncio.sleep(0.05)
+132 -1
View File
@@ -15,6 +15,8 @@
"""Unit tests for Artemis MCP Tools."""
import inspect
import json
import os
import shutil
import tempfile
from types import SimpleNamespace
@@ -49,6 +51,7 @@ def test_tool_signatures():
assert "locked_app_package" in sig_run.parameters
assert "app_path" in sig_run.parameters
assert "expected_output_desc" in sig_run.parameters
assert "device_serial" in sig_run.parameters
# mobile_manage_task signature check
sig_manage = inspect.signature(mobile_manage_task)
@@ -59,6 +62,7 @@ def test_tool_signatures():
# mobile_get_device_state signature check
sig_device = inspect.signature(mobile_get_device_state)
assert "view_type" in sig_device.parameters
assert "device_serial" in sig_device.parameters
# mobile_inspect_trace signature check
sig_inspect = inspect.signature(mobile_inspect_trace)
@@ -97,10 +101,54 @@ def test_mobile_run_task_reserves_and_passes_global_queue_ticket(temp_trace_env)
ingress="mcp",
)
assert result["trace_id"]
assert result["device_serial"] == "auto-select"
assert popen.call_args.kwargs["env"]["ARTEMIS_DEVICE_QUEUE_TICKET"] == "queue-ticket-1"
assert popen.call_args.kwargs["env"]["ARTEMIS_TASK_INGRESS"] == "mcp"
status = trace_store.read_status(result["trace_id"])
assert status["queue_ticket"] == "queue-ticket-1"
assert status["device_serial"] is None
def test_mobile_run_task_with_device_serial(temp_trace_env):
process = MagicMock(pid=54321)
with (
patch(
"mcp_server.tools.task_runner.DeviceExecutionLock.reserve",
return_value="queue-ticket-dev",
) as reserve,
patch("mcp_server.tools.task_runner.DeviceExecutionLock.transfer_reservation") as transfer,
patch("mcp_server.tools.task_runner.subprocess.Popen", return_value=process) as popen,
):
result = mobile_run_task(
task_desc="Open Settings on target phone",
conversation_id="conv-2",
model="Flash",
device_serial="pixel-11-pro-001",
)
reserve.assert_called_once_with(
description="MCP task: Open Settings on target phone",
session_id=result["trace_id"],
ingress="mcp",
device_id="pixel-11-pro-001",
)
transfer.assert_called_once_with(
"queue-ticket-dev",
54321,
description="MCP task: Open Settings on target phone",
session_id=result["trace_id"],
ingress="mcp",
device_id="pixel-11-pro-001",
)
assert result["device_serial"] == "pixel-11-pro-001"
cmd = popen.call_args.args[0]
assert "--device-serial" in cmd
assert cmd[cmd.index("--device-serial") + 1] == "pixel-11-pro-001"
assert popen.call_args.kwargs["env"]["ADB_DEVICE_SERIAL"] == "pixel-11-pro-001"
assert popen.call_args.kwargs["env"]["ARTEMIS_DEVICE_ID"] == "pixel-11-pro-001"
status = trace_store.read_status(result["trace_id"])
assert status["device_serial"] == "pixel-11-pro-001"
def test_mobile_manage_task_unknown_trace(temp_trace_env):
@@ -111,13 +159,14 @@ def test_mobile_manage_task_unknown_trace(temp_trace_env):
def test_mobile_manage_task_status_and_stop(temp_trace_env):
trace_id = str(uuid.uuid4())
trace_store.init_trace(trace_id, "Test task", "Flash", "conv-1")
trace_store.init_trace(trace_id, "Test task", "Flash", "conv-1", device_serial="device-xyz")
# Status check
status_res = mobile_manage_task(action="status", trace_id=trace_id)
assert status_res["trace_id"] == trace_id
assert status_res["status"] == "running"
assert status_res["model"] == "Flash"
assert status_res["device_serial"] == "device-xyz"
# Stop without PID
stop_res = mobile_manage_task(action="stop", trace_id=trace_id)
@@ -125,6 +174,31 @@ def test_mobile_manage_task_status_and_stop(temp_trace_env):
assert "PID" in stop_res["message"] or "Cannot stop" in stop_res["message"]
def test_mobile_manage_task_status_backfills_device_serial_from_db(temp_trace_env):
import sqlite3
import json
import os
trace_id = str(uuid.uuid4())
trace_store.init_trace(trace_id, "Test task without serial", "Flash", "conv-1")
# Create dummy data_engine.db with session record
db_path = os.path.join(temp_trace_env, "data_engine.db")
conn = sqlite3.connect(db_path)
conn.execute(
"CREATE TABLE sessions (session_id TEXT PRIMARY KEY, device_info TEXT, pid INTEGER, start_time REAL)"
)
conn.execute(
"INSERT INTO sessions VALUES (?, ?, ?, ?)",
(trace_id, json.dumps({"device_id": "auto-chosen-serial-777"}), 12345, 1000.0),
)
conn.commit()
conn.close()
status_res = mobile_manage_task(action="status", trace_id=trace_id)
assert status_res["device_serial"] == "auto-chosen-serial-777"
def test_mobile_manage_task_inject_instruction(temp_trace_env):
trace_id = str(uuid.uuid4())
trace_store.init_trace(trace_id, "Test task", "Pro", "conv-1")
@@ -181,6 +255,63 @@ async def test_mobile_inspect_trace_invalid_action():
assert "not supported" in res["message"]
@pytest.mark.asyncio
async def test_mobile_get_device_state_passes_device_serial():
"""Verify that device_serial is forwarded to _get_controller."""
controller = MagicMock()
controller.ctx.device.device_width = 1080
controller.ctx.device.device_height = 2400
controller.get_screen_data = AsyncMock(
return_value=SimpleNamespace(
base64="ZHVtbXk=",
elements=[],
width=1080,
height=2400,
)
)
with (
patch("mcp_server.tools.device_state._get_controller", return_value=controller) as get_ctrl_mock,
patch("mcp_server.tools.device_state.is_ocr_configured", return_value=False),
):
await mobile_get_device_state(view_type="hierarchy", device_serial="device-serial-abc")
get_ctrl_mock.assert_called_once_with(device_serial="device-serial-abc")
@pytest.mark.asyncio
async def test_mobile_inspect_trace_includes_device_serial(temp_trace_env):
import sqlite3
trace_id = str(uuid.uuid4())
trace_store.init_trace(trace_id, "Inspect test", "Flash", "conv-1", device_serial="pixel-target-999")
# Create dummy database and table
db_path = os.path.join(temp_trace_env, "data_engine.db")
conn = sqlite3.connect(db_path)
conn.execute("CREATE TABLE sessions (session_id TEXT PRIMARY KEY, start_time REAL, device_info TEXT)")
conn.execute("CREATE TABLE steps (step_id TEXT PRIMARY KEY, session_id TEXT, step_number INTEGER, timestamp REAL, pre_image_name TEXT, post_image_name TEXT, summary TEXT, action_taken TEXT, operator_raw_thinking TEXT, last_execution_result TEXT, extra_metadata TEXT)")
conn.execute("CREATE TABLE traces (trace_id TEXT PRIMARY KEY, step_id TEXT, session_id TEXT, type TEXT, name TEXT, status TEXT, timestamp REAL, duration REAL, payload TEXT)")
conn.execute("INSERT INTO sessions VALUES (?, ?, ?)", (trace_id, 1000.0, None))
conn.commit()
conn.close()
res_summary = await mobile_inspect_trace(action="view_summary", trace_id=trace_id)
assert "**Device Serial:** `pixel-target-999`" in res_summary
def test_trace_store_device_serial(temp_trace_env):
trace_id = "test-store-serial"
data = trace_store.init_trace(trace_id, "desc", "Flash", "conv", device_serial="init-serial")
assert data["device_serial"] == "init-serial"
data_updated = trace_store.update_trace_device_serial(trace_id, "updated-serial")
assert data_updated["device_serial"] == "updated-serial"
read_back = trace_store.read_status(trace_id)
assert read_back["device_serial"] == "updated-serial"
@pytest.mark.asyncio
async def test_mcp_server_auto_registers_tools():
"""Verify that importing mcp_server.base auto-registers all mobile tools without manual tools import."""
+103
View File
@@ -0,0 +1,103 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from unittest.mock import MagicMock, patch
import pytest
from artemis.runtime.daemon_client import (
ensure_daemon_running,
is_daemon_running,
is_standalone_forced,
spawn_daemon,
submit_task_to_daemon,
)
def test_is_standalone_forced(monkeypatch):
monkeypatch.delenv("ARTEMIS_STANDALONE", raising=False)
assert is_standalone_forced() is False
monkeypatch.setenv("ARTEMIS_STANDALONE", "1")
assert is_standalone_forced() is True
monkeypatch.setenv("ARTEMIS_STANDALONE", "true")
assert is_standalone_forced() is True
monkeypatch.setenv("ARTEMIS_STANDALONE", "0")
assert is_standalone_forced() is False
def test_is_daemon_running_success():
mock_resp = MagicMock()
mock_resp.status = 200
mock_resp.__enter__.return_value = mock_resp
with patch("urllib.request.urlopen", return_value=mock_resp):
assert is_daemon_running() is True
def test_is_daemon_running_failure():
with patch("urllib.request.urlopen", side_effect=OSError("Connection refused")):
assert is_daemon_running() is False
def test_spawn_daemon():
mock_proc = MagicMock()
mock_proc.pid = 54321
with patch("subprocess.Popen", return_value=mock_proc) as mock_popen:
proc = spawn_daemon(host="127.0.0.1", port=8000)
assert proc is mock_proc
assert mock_popen.called
def test_ensure_daemon_running_when_already_active():
with patch("artemis.runtime.daemon_client.is_daemon_running", return_value=True):
ok, base_url = ensure_daemon_running(host="127.0.0.1", port=8000)
assert ok is True
assert base_url == "http://127.0.0.1:8000"
def test_ensure_daemon_running_standalone_override(monkeypatch):
monkeypatch.setenv("ARTEMIS_STANDALONE", "1")
ok, base_url = ensure_daemon_running()
assert ok is False
assert base_url is None
def test_ensure_daemon_running_auto_spawns():
with (
patch("artemis.runtime.daemon_client.is_daemon_running", side_effect=[False, True]),
patch("artemis.runtime.daemon_client.spawn_daemon") as mock_spawn,
):
ok, base_url = ensure_daemon_running(timeout=1.0, wait_ready=True)
assert ok is True
assert base_url == "http://127.0.0.1:8000"
mock_spawn.assert_called_once()
def test_submit_task_to_daemon():
mock_resp = MagicMock()
mock_resp.status = 200
mock_resp.read.return_value = b'{"status": "queued", "tasks": [{"session_id": "test-sess"}]}'
mock_resp.__enter__.return_value = mock_resp
with patch("urllib.request.urlopen", return_value=mock_resp):
res = submit_task_to_daemon(
"test goal",
profile="flash",
device_serial="pixel-8",
)
assert res is not None
assert res["status"] == "queued"
assert res["tasks"][0]["session_id"] == "test-sess"
+94
View File
@@ -227,3 +227,97 @@ def test_active_owner_is_discoverable_and_can_be_annotated(monkeypatch):
assert annotated.ingress == "cli"
finally:
owner_lock.release()
def test_multi_device_locks_can_run_concurrently():
lock_a = DeviceExecutionLock("emulator-5554", "task on device A")
lock_b = DeviceExecutionLock("pixel-9-test", "task on device B")
lock_a.acquire()
try:
# Device B should be acquired without blocking since it's a different device
lock_b.acquire(blocking=False)
try:
active_owners = DeviceExecutionLock.get_active_owners()
assert "emulator-5554" in active_owners
assert "pixel-9-test" in active_owners
# A second task on device A should be blocked
lock_a_second = DeviceExecutionLock("emulator-5554", "second task on device A")
with pytest.raises(DeviceBusyError, match="task on device A"):
lock_a_second.acquire(blocking=False)
finally:
lock_b.release()
finally:
lock_a.release()
def test_global_concurrency_serial_mode_blocks_other_devices(monkeypatch):
monkeypatch.setenv("ARTEMIS_MAX_CONCURRENT_TASKS", "1")
lock_a = DeviceExecutionLock("device_alpha", "task alpha")
lock_b = DeviceExecutionLock("device_beta", "task beta")
lock_a.acquire()
try:
with pytest.raises(DeviceBusyError, match="concurrency limit"):
lock_b.acquire(blocking=False)
finally:
lock_a.release()
# Once lock A is released, lock B can acquire
lock_b.acquire(blocking=False)
lock_b.release()
def test_owner_is_alive_guards_against_recycled_pid():
from unittest.mock import MagicMock, patch
from artemis.runtime.device_lock import DeviceLockOwner
owner = DeviceLockOwner(
pid=99999,
process_created_at=0.0,
token="token-1",
device_id="dev-1",
description="test",
acquired_at="now",
)
# 1. PID is 0 or negative -> immediately dead
invalid_owner = DeviceLockOwner(
pid=0,
process_created_at=0.0,
token="token-1",
device_id="dev-1",
description="test",
acquired_at="now",
)
assert DeviceExecutionLock._owner_is_alive(invalid_owner) is False
# 2. Recycled PID belongs to non-Artemis system process -> treated as dead
mock_proc = MagicMock()
mock_proc.is_running.return_value = True
mock_proc.name.return_value = "systemd"
mock_proc.cmdline.return_value = ["/sbin/init"]
with patch("psutil.Process", return_value=mock_proc):
assert DeviceExecutionLock._owner_is_alive(owner) is False
# 3. PID belongs to a Python / Artemis process -> alive
mock_proc.name.return_value = "python3"
mock_proc.cmdline.return_value = ["python3", "-m", "artemis.main"]
with patch("psutil.Process", return_value=mock_proc):
assert DeviceExecutionLock._owner_is_alive(owner) is True
def test_safe_unlink_and_replace_retry(tmp_path):
f = tmp_path / "test_file.txt"
f.write_text("hello")
assert DeviceExecutionLock._safe_unlink(f) is True
assert not f.exists()
src = tmp_path / "src.txt"
dst = tmp_path / "dst.txt"
src.write_text("world")
assert DeviceExecutionLock._safe_replace(src, dst) is True
assert dst.read_text() == "world"
+85
View File
@@ -0,0 +1,85 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import pytest
from artemis.runtime.device_lock import DeviceExecutionLock
from artemis.runtime.device_pool import DevicePool, DeviceStatus
@pytest.fixture(autouse=True)
def isolated_lock_directory(tmp_path, monkeypatch):
monkeypatch.setattr(
"artemis.runtime.device_lock.get_temp_dir",
lambda _name: tmp_path,
)
def test_device_pool_parse_lines():
lines = [
"List of devices attached",
"emulator-5554 device product:sdk_gphone64_arm64 model:sdk_gphone64_arm64 device:emu64a",
"9A123XYZ device product:husky model:Pixel_8_Pro device:husky",
"offline-dev offline",
"",
]
parsed = DevicePool._parse_device_lines(lines)
assert len(parsed) == 3
assert parsed[0] == ("emulator-5554", "device", "sdk gphone64 arm64", "sdk_gphone64_arm64")
assert parsed[1] == ("9A123XYZ", "device", "Pixel 8 Pro", "husky")
assert parsed[2] == ("offline-dev", "offline", None, None)
def test_device_pool_list_devices_and_busy_status(monkeypatch):
pool = DevicePool()
monkeypatch.setattr(
pool,
"_query_adb_devices_sync",
lambda: [
("emulator-5554", "device", "Emulator", "sdk"),
("pixel-8-pro", "device", "Pixel 8 Pro", "husky"),
],
)
# Initially neither is busy
devs = pool.list_devices()
assert len(devs) == 2
assert not devs[0].is_busy
assert not devs[1].is_busy
# Acquire lock for emulator-5554
lock = DeviceExecutionLock("emulator-5554", "test automation")
lock.acquire()
try:
devs = pool.list_devices()
emu = next(d for d in devs if d.serial == "emulator-5554")
pixel = next(d for d in devs if d.serial == "pixel-8-pro")
assert emu.is_busy is True
assert emu.active_task_desc == "test automation"
assert pixel.is_busy is False
# Test idle device filtering
idle = pool.get_idle_devices()
assert len(idle) == 1
assert idle[0].serial == "pixel-8-pro"
# Test select_device picks the idle device automatically
selected = pool.select_device()
assert selected == "pixel-8-pro"
# Test explicit selection returns requested device
assert pool.select_device("emulator-5554") == "emulator-5554"
finally:
lock.release()
@@ -0,0 +1,201 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for Artemis Server Lifecycle Management and API Endpoints."""
import json
import os
from unittest.mock import MagicMock, patch
import pytest
from httpx import ASGITransport, AsyncClient
from apps.admin_console.server import app
from artemis.runtime.server_lifecycle import (
clear_server_info,
find_server_pids,
get_server_status,
is_port_in_use,
read_server_info,
stop_server,
write_server_info,
)
def test_write_read_clear_server_info(tmp_path, monkeypatch):
"""Verify write, read, and clear round-trip for server metadata file."""
fake_info_file = tmp_path / "test_server.json"
monkeypatch.setattr(
"artemis.runtime.server_lifecycle.get_server_info_file", lambda: fake_info_file
)
# Initial read should be None
assert read_server_info() is None
# Write info
written = write_server_info(port=8888, host="127.0.0.1", pid=99999)
assert written == fake_info_file
assert fake_info_file.exists()
# Read back
info = read_server_info()
assert info is not None
assert info["pid"] == 99999
assert info["port"] == 8888
assert info["host"] == "127.0.0.1"
assert "started_at" in info
# Clear info
clear_server_info()
assert not fake_info_file.exists()
assert read_server_info() is None
def test_find_server_pids_from_metadata(tmp_path, monkeypatch):
"""Verify find_server_pids discovers PID saved in metadata file."""
fake_info_file = tmp_path / "test_server.json"
fake_info_file.write_text(
json.dumps({"pid": os.getpid(), "port": 8000, "started_at": 100.0}),
encoding="utf-8",
)
monkeypatch.setattr(
"artemis.runtime.server_lifecycle.get_server_info_file", lambda: fake_info_file
)
monkeypatch.setattr("artemis.runtime.server_lifecycle.is_port_in_use", lambda port, **k: True)
pids = find_server_pids(8000)
assert os.getpid() in pids
def test_get_server_status_offline(monkeypatch):
"""Verify get_server_status returns correct structure when server is offline."""
monkeypatch.setattr("artemis.runtime.server_lifecycle.is_port_in_use", lambda port, **k: False)
monkeypatch.setattr("artemis.runtime.server_lifecycle.find_server_pids", lambda port: [])
monkeypatch.setattr("artemis.runtime.server_lifecycle.read_server_info", lambda: None)
status = get_server_status(8000)
assert status["running"] is False
assert status["port"] == 8000
assert status["pids"] == []
assert status["url"] is None
def test_get_server_status_online(monkeypatch):
"""Verify get_server_status returns correct structure when server is online."""
monkeypatch.setattr("artemis.runtime.server_lifecycle.is_port_in_use", lambda port, **k: True)
monkeypatch.setattr("artemis.runtime.server_lifecycle.find_server_pids", lambda port: [12345])
monkeypatch.setattr(
"artemis.runtime.server_lifecycle.read_server_info",
lambda: {"pid": 12345, "port": 8000, "started_at": 1000.0},
)
status = get_server_status(8000)
assert status["running"] is True
assert status["port"] == 8000
assert status["pids"] == [12345]
assert status["active_pid"] == 12345
assert status["url"] == "http://localhost:8000"
assert status["admin_url"] == "http://localhost:8000/admin"
assert status["uptime_seconds"] is not None
def test_stop_server_when_not_running(monkeypatch):
"""Verify stop_server returns early when no server is running."""
monkeypatch.setattr("artemis.runtime.server_lifecycle.is_port_in_use", lambda port, **k: False)
monkeypatch.setattr("artemis.runtime.server_lifecycle.find_server_pids", lambda port: [])
success, msg, stopped = stop_server(8000)
assert success is True
assert stopped == []
assert "No active server" in msg
def test_stop_server_terminates_tree(monkeypatch):
"""Verify stop_server calls ProcessSupervisor.terminate_tree and returns stopped pids."""
terminated_pids = []
monkeypatch.setattr("artemis.runtime.server_lifecycle.find_server_pids", lambda port: [12345])
port_in_use = [True, False]
def mock_in_use(port, **kwargs):
if port_in_use:
return port_in_use.pop(0)
return False
monkeypatch.setattr("artemis.runtime.server_lifecycle.is_port_in_use", mock_in_use)
monkeypatch.setattr(
"artemis.runtime.supervisor.ProcessSupervisor.terminate_tree",
lambda pid, timeout_seconds=4.0: terminated_pids.append(pid),
)
monkeypatch.setattr(
"artemis.runtime.device_lock.DeviceExecutionLock.cleanup_stale_locks",
lambda: 1,
)
success, msg, stopped = stop_server(8000)
assert success is True
assert stopped == [12345]
assert 12345 in terminated_pids
assert "stopped" in msg.lower()
@pytest.mark.asyncio
async def test_api_server_status():
"""Verify GET /api/system/server-status returns server runtime metadata."""
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://testserver"
) as client:
response = await client.get("/api/system/server-status")
assert response.status_code == 200
data = response.json()
assert "port" in data
assert "running" in data
assert "current_pid" in data
@pytest.mark.asyncio
async def test_api_restart_endpoint():
"""Verify POST /api/system/restart accepts request and schedules restart."""
with patch("threading.Thread") as mock_thread:
mock_instance = MagicMock()
mock_thread.return_value = mock_instance
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://testserver"
) as client:
response = await client.post("/api/system/restart")
assert response.status_code == 200
data = response.json()
assert data["status"] == "restarting"
assert "reconnection" in data["message"].lower() or "restarting" in data["message"].lower()
mock_thread.assert_called_once()
mock_instance.start.assert_called_once()
@pytest.mark.asyncio
async def test_api_shutdown_endpoint():
"""Verify POST /api/system/shutdown accepts request and schedules shutdown."""
with patch("threading.Thread") as mock_thread:
mock_instance = MagicMock()
mock_thread.return_value = mock_instance
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://testserver"
) as client:
response = await client.post("/api/system/shutdown")
assert response.status_code == 200
data = response.json()
assert data["status"] == "shutting_down"
mock_thread.assert_called_once()
mock_instance.start.assert_called_once()
@@ -113,3 +113,34 @@ async def test_locked_work_profile_does_not_block_unlocked_device_owner():
)
await agent._ensure_device_unlocked()
@pytest.mark.asyncio
async def test_agent_inherits_session_id_from_env_and_propagates_to_tracing(monkeypatch):
import uuid
canonical_sid = "a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d"
monkeypatch.setenv("ARTEMIS_SESSION_ID", canonical_sid)
agent = Agent()
assert agent._session_id == canonical_sid
task = MagicMock()
task.id = "fallback-id"
task.get_name.return_value = "task-name"
task.request.goal = "test goal"
task.request.profile = "flash"
task.request.task_name = "task-name"
task.request.trace_path = "traces"
context = MagicMock()
context.device = None
with patch("artemis.sdk.agent.DataEngine") as mock_data_engine_cls:
mock_data_engine = MagicMock()
mock_data_engine_cls.return_value = mock_data_engine
agent._prepare_tracing(task=task, context=context)
mock_data_engine.start_session.assert_called_once()
call_kwargs = mock_data_engine.start_session.call_args.kwargs
assert call_kwargs["session_id"] == uuid.UUID(canonical_sid)
+106 -3
View File
@@ -198,9 +198,8 @@ def test_cli_mcp_install_all(tmp_path, monkeypatch):
claude_data = json.loads((tmp_path / ".claude.json").read_text())
assert claude_data["mcpServers"]["artemis"]["type"] == "stdio"
vscode_data = json.loads(
(tmp_path / "AppData" / "Roaming" / "Code" / "User" / "mcp.json").read_text()
)
from artemis.interfaces.cli.commands.mcp import _get_vscode_user_dir
vscode_data = json.loads((_get_vscode_user_dir() / "mcp.json").read_text())
assert vscode_data["servers"]["artemis"]["type"] == "stdio"
assert "mcpServers" not in vscode_data
@@ -333,3 +332,107 @@ def test_cli_mcp_install_openclaw_migrates_legacy_plugin_shape(tmp_path, monkeyp
assert data["mcp"]["servers"]["artemis"]["command"]
def test_cli_restart_help():
"""Verify 'artemis restart --help' displays lifecycle options."""
result = runner.invoke(app, ["restart", "--help"])
assert result.exit_code == 0
assert "--port" in result.output
assert "--host" in result.output
assert "--force" in result.output
assert "--daemon" in result.output
assert "--open" in result.output
def test_cli_stop_help():
"""Verify 'artemis stop --help' displays stop options."""
result = runner.invoke(app, ["stop", "--help"])
assert result.exit_code == 0
assert "--port" in result.output
assert "--force" in result.output
def test_cli_status_help():
"""Verify 'artemis status --help' displays status options."""
result = runner.invoke(app, ["status", "--help"])
assert result.exit_code == 0
assert "--port" in result.output
def test_cli_status_offline(monkeypatch):
"""Verify 'artemis status' reports offline when port is unused."""
from artemis.runtime import server_lifecycle
monkeypatch.setattr(server_lifecycle, "is_port_in_use", lambda port, **kwargs: False)
monkeypatch.setattr(server_lifecycle, "find_server_pids", lambda port: [])
monkeypatch.setattr(server_lifecycle, "read_server_info", lambda: None)
result = runner.invoke(app, ["status", "--port", "59998"])
assert result.exit_code == 0
assert "OFFLINE" in result.output or "STOPPED" in result.output
def test_cli_status_online(monkeypatch):
"""Verify 'artemis status' reports online details when server is active."""
from artemis.runtime import server_lifecycle
monkeypatch.setattr(server_lifecycle, "is_port_in_use", lambda port, **kwargs: True)
monkeypatch.setattr(server_lifecycle, "find_server_pids", lambda port: [12345])
monkeypatch.setattr(
server_lifecycle,
"read_server_info",
lambda: {"pid": 12345, "port": 8000, "started_at": 1000.0, "cwd": "/tmp"},
)
result = runner.invoke(app, ["status", "--port", "8000"])
assert result.exit_code == 0
assert "ONLINE" in result.output or "RUNNING" in result.output
assert "12345" in result.output
def test_cli_stop_command(monkeypatch):
"""Verify 'artemis stop' invokes stop_server with given parameters."""
from artemis.interfaces.cli.commands import server_lifecycle as sl_cmd
mock_called = {}
def mock_stop(port, timeout=4.0, force=False):
mock_called["port"] = port
mock_called["force"] = force
return True, "Artemis server stopped (PID: 12345).", [12345]
monkeypatch.setattr(sl_cmd, "find_server_pids", lambda port: [12345])
monkeypatch.setattr(sl_cmd, "stop_server", mock_stop)
result = runner.invoke(app, ["stop", "--port", "8000", "--force"])
assert result.exit_code == 0
assert mock_called["port"] == 8000
assert mock_called["force"] is True
assert "stopped successfully" in result.output.lower() or "PID: 12345" in result.output
def test_cli_restart_command(monkeypatch):
"""Verify 'artemis restart' stops previous server and invokes ui_command."""
from artemis.interfaces.cli.commands import server_lifecycle as sl_cmd
from artemis.runtime import server_lifecycle
stopped = {}
ui_called = {}
def mock_stop(port, timeout=4.0, force=False):
stopped["port"] = port
return True, "Stopped server", [12345]
def mock_ui(host, port, open_browser, reload):
ui_called["host"] = host
ui_called["port"] = port
ui_called["open_browser"] = open_browser
monkeypatch.setattr(server_lifecycle, "find_server_pids", lambda port: [12345])
monkeypatch.setattr(sl_cmd, "stop_server", mock_stop)
monkeypatch.setattr(sl_cmd, "ui_command", mock_ui)
result = runner.invoke(app, ["restart", "--port", "8888", "--no-open"])
assert result.exit_code == 0
assert stopped["port"] == 8888
assert ui_called["port"] == 8888
assert ui_called["open_browser"] is False
+1 -1
View File
@@ -21,7 +21,7 @@ from artemis.agents.operator.prompt_builder import OperatorPromptBuilder
def test_operator_prompt_builder():
"""Verify prompt rendering formats variables correctly."""
system_msg = OperatorPromptBuilder.build_system_message()
assert "ARTEMIS Operator" in system_msg
assert "Android Device Operator" in system_msg
assert "click" in system_msg
human_msg = OperatorPromptBuilder.build_human_message(
+128
View File
@@ -64,3 +64,131 @@ async def test_artemis_client_stream_run():
assert events[0].event_type == StreamEventType.STATUS
assert events[0].payload.get("status") == "starting"
assert len(collected_events) == len(events)
@pytest.mark.asyncio
async def test_client_device_specification():
"""Verify target device can be set via init, setters, or per-task run parameters."""
client = ArtemisClient(device_serial="emulator-5554", default_profile="flash")
assert client.device_id == "emulator-5554"
assert client.device_serial == "emulator-5554"
# Mutate via property setter
client.device_serial = "pixel-9-pro"
assert client.device_id == "pixel-9-pro"
assert client.device_serial == "pixel-9-pro"
# Mutate via chaining method
client.set_device("pixel-8")
assert client.device_id == "pixel-8"
# Override device per run call using device_serial
res1 = await client.run("Task on dev 1", device_serial="target-device-alpha")
assert res1.device_id == "target-device-alpha"
# Override device per run call using device_id
res2 = await client.run("Task on dev 2", device_id="target-device-beta")
assert res2.device_id == "target-device-beta"
@pytest.mark.asyncio
async def test_client_stream_run_device_specification():
"""Verify stream_run propagates custom target device."""
client = ArtemisClient(device_id="default-stream-dev", default_profile="flash")
events = []
async for ev in client.stream_run("Test goal", device_serial="explicit-stream-dev"):
events.append(ev)
assert len(events) >= 1
assert events[0].payload.get("device_id") == "explicit-stream-dev"
@pytest.mark.asyncio
async def test_client_concurrency_mode_configuration():
"""Verify concurrency_mode can be configured as 'global' or 'per_device'."""
from artemis import ConcurrencyMode
client = ArtemisClient(concurrency_mode=ConcurrencyMode.GLOBAL)
assert client.concurrency_mode == "global"
client.set_concurrency_mode(ConcurrencyMode.PER_DEVICE)
assert client.concurrency_mode == "per_device"
client.concurrency_mode = "global"
assert client.concurrency_mode == "global"
@pytest.mark.asyncio
async def test_sdk_multi_device_parallel_execution():
"""Verify per_device concurrency allows tasks on different devices to run concurrently."""
import asyncio
client_a = ArtemisClient(device_id="mock-dev-parallel-1", concurrency_mode="per_device")
client_b = ArtemisClient(device_id="mock-dev-parallel-2", concurrency_mode="per_device")
# Run tasks on two different devices concurrently
task_a = asyncio.create_task(client_a.run("Task on device 1"))
task_b = asyncio.create_task(client_b.run("Task on device 2"))
res_a, res_b = await asyncio.gather(task_a, task_b)
assert res_a.status in ("completed", "running", "success")
assert res_b.status in ("completed", "running", "success")
assert res_a.device_id == "mock-dev-parallel-1"
assert res_b.device_id == "mock-dev-parallel-2"
@pytest.mark.asyncio
async def test_sdk_global_concurrency_serializes_across_devices():
"""Verify global concurrency restricts execution to 1 task across all devices."""
import asyncio
from artemis.runtime import DeviceBusyError, DeviceExecutionLock
# Pre-acquire a lock on device A with global concurrency mode
lock_a = DeviceExecutionLock("mock-global-dev-1", "active global task", concurrency_mode="global")
lock_a.acquire()
try:
client_b = ArtemisClient(device_id="mock-global-dev-2", concurrency_mode="global")
# Attempting non-blocking execution on device B should fail because global concurrency limit (1) is held by device A
with pytest.raises(DeviceBusyError, match="Global task concurrency limit"):
await client_b.run("Task on device 2", blocking=False)
finally:
lock_a.release()
@pytest.mark.asyncio
async def test_sdk_run_task_with_device_and_concurrency():
"""Verify Task object with device_serial and concurrency_mode executes via run_task."""
client = ArtemisClient()
task = Task(
goal="Verify search functionality",
profile="flash",
device_serial="device-from-task-obj",
concurrency_mode="per_device",
)
assert task.device_id == "device-from-task-obj"
assert task.concurrency_mode == "per_device"
res = await client.run_task(task)
assert res.device_id == "device-from-task-obj"
assert res.status in ("completed", "running", "success")
def test_builder_and_agent_concurrency_and_device_targeting():
"""Verify AgentConfigBuilder and Agent support device targeting and concurrency configuration."""
from artemis import Agent, Builders
# 1. Builder supports string for_device directly
cfg1 = Builders.AgentConfig.for_device("emulator-9999").with_concurrency_mode("global").build()
assert cfg1.device_id == "emulator-9999"
assert cfg1.concurrency_mode == "global"
# 2. Builder supports for_device_serial
cfg2 = Builders.AgentConfig.for_device_serial("pixel-test-serial").with_concurrency_mode("per_device").build()
assert cfg2.device_id == "pixel-test-serial"
assert cfg2.concurrency_mode == "per_device"
# 3. Agent supports device_serial and concurrency_mode directly in constructor
agent = Agent(device_serial="pixel-constructor-dev", concurrency_mode="global")
assert agent._config.device_id == "pixel-constructor-dev"
assert agent._config.concurrency_mode == "global"