feat: add global device queue and readiness tracking
This commit is contained in:
1 parent
a62ac4aef1
commit
42b8988661
57 files changed
+3606
-716
No files matched your search
@@ -39,6 +39,7 @@ class ServerState:
|
||||
self.active_session_id: str | None = None
|
||||
self.was_stopped_manually: bool = False
|
||||
self.cancelled_session_ids: set[str] = set()
|
||||
self.startup_progress: dict[str, list[dict[str, Any]]] = {}
|
||||
|
||||
# Unified single source of truth for task queue
|
||||
self.queue_items: list[dict[str, Any]] = []
|
||||
@@ -171,6 +172,29 @@ class ServerState:
|
||||
if callback in self.ipc_subscribers:
|
||||
self.ipc_subscribers.remove(callback)
|
||||
|
||||
def record_startup_progress(self, data: dict[str, Any]) -> None:
|
||||
"""Retain the short pre-trace timeline so late SSE clients can catch up."""
|
||||
session_id = data.get("session_id")
|
||||
stage = data.get("stage")
|
||||
if not session_id or not stage:
|
||||
return
|
||||
|
||||
key = str(session_id)
|
||||
events = self.startup_progress.setdefault(key, [])
|
||||
replacement_index = next(
|
||||
(index for index, item in enumerate(events) if item.get("stage") == stage),
|
||||
None,
|
||||
)
|
||||
snapshot = dict(data)
|
||||
if replacement_index is None:
|
||||
events.append(snapshot)
|
||||
else:
|
||||
events[replacement_index] = snapshot
|
||||
self.startup_progress[key] = events[-16:]
|
||||
|
||||
def get_startup_progress(self, session_id: str) -> list[dict[str, Any]]:
|
||||
return [dict(item) for item in self.startup_progress.get(str(session_id), [])]
|
||||
|
||||
def clear_queue(self):
|
||||
"""Clears all pending items from the task queue."""
|
||||
self.queue_items = [t for t in self.queue_items if t.get("status") == "running"]
|
||||
|
||||
@@ -27,6 +27,17 @@ class SessionRepository:
|
||||
def __init__(self, db_path=None):
|
||||
self.db_path = db_path
|
||||
|
||||
@staticmethod
|
||||
def process_is_alive(pid: Any) -> bool:
|
||||
"""Return whether a session's worker PID is still alive."""
|
||||
try:
|
||||
import psutil
|
||||
|
||||
process = psutil.Process(int(pid))
|
||||
return process.is_running() and process.status() != psutil.STATUS_ZOMBIE
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def get_all_sessions(self) -> list[dict[str, Any]]:
|
||||
with db_session(self.db_path) as conn:
|
||||
cursor = conn.cursor()
|
||||
@@ -135,24 +146,41 @@ class SessionRepository:
|
||||
with db_session(self.db_path) as conn:
|
||||
cursor = conn.cursor()
|
||||
now = time.time()
|
||||
count = 0
|
||||
for s_id in orphaned_ids:
|
||||
row = cursor.execute(
|
||||
"SELECT pid FROM sessions WHERE session_id = ? AND status = ?",
|
||||
(s_id, "running"),
|
||||
).fetchone()
|
||||
if row is None or self.process_is_alive(row["pid"]):
|
||||
continue
|
||||
cursor.execute(
|
||||
"UPDATE sessions SET status = ?, end_time = ? "
|
||||
"WHERE session_id = ? AND status = ?",
|
||||
("failed", now, s_id, "running"),
|
||||
)
|
||||
count += cursor.rowcount
|
||||
conn.commit()
|
||||
return len(orphaned_ids)
|
||||
return count
|
||||
|
||||
def cleanup_orphans_on_startup(self) -> int:
|
||||
try:
|
||||
with db_session(self.db_path) as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"UPDATE sessions SET status = ?, end_time = ? WHERE status = ?",
|
||||
("failed", time.time(), "running"),
|
||||
)
|
||||
count = cursor.rowcount
|
||||
rows = cursor.execute(
|
||||
"SELECT session_id, pid FROM sessions WHERE status = ?", ("running",)
|
||||
).fetchall()
|
||||
count = 0
|
||||
now = time.time()
|
||||
for row in rows:
|
||||
if self.process_is_alive(row["pid"]):
|
||||
continue
|
||||
cursor.execute(
|
||||
"UPDATE sessions SET status = ?, end_time = ? "
|
||||
"WHERE session_id = ? AND status = ?",
|
||||
("failed", now, row["session_id"], "running"),
|
||||
)
|
||||
count += cursor.rowcount
|
||||
conn.commit()
|
||||
return count
|
||||
except Exception:
|
||||
|
||||
@@ -52,7 +52,8 @@ async def list_sessions():
|
||||
is_active = s_id in state.active_connections or (
|
||||
state.is_running and s_id == str(state.active_session_id)
|
||||
)
|
||||
if not is_active:
|
||||
worker_is_alive = session_repo.process_is_alive(row_dict.get("pid"))
|
||||
if not is_active and not worker_is_alive:
|
||||
row_dict["status"] = "failed"
|
||||
row_dict["end_time"] = time.time()
|
||||
orphaned_ids.append(s_id)
|
||||
|
||||
@@ -17,6 +17,8 @@ from contextlib import suppress
|
||||
import json
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from artemis.core.diagnostics import readiness_engine
|
||||
from artemis.runtime import DeviceExecutionLock
|
||||
|
||||
try:
|
||||
from admin_console.core.state import state
|
||||
@@ -77,6 +79,19 @@ async def run_task(request: RunRequest):
|
||||
detail="Either 'goal' or 'goals' list must be provided.",
|
||||
)
|
||||
|
||||
# Re-check immediately before enqueueing so a device locked between UI
|
||||
# polling intervals cannot start through a stale Ready state. Use the
|
||||
# bounded submission probe: the full diagnostics path also scans packages,
|
||||
# emulator installations, Android version, and screen size.
|
||||
device_probe = await readiness_engine.run_device_submission_probe()
|
||||
if device_probe and device_probe.summary in {"Device Locked", "Lock State Unknown"}:
|
||||
detail = (
|
||||
"Android device is locked. Unlock it and enter the home screen before running a task."
|
||||
if device_probe.summary == "Device Locked"
|
||||
else "Android device lock state could not be verified. Keep it unlocked on the home screen and try again."
|
||||
)
|
||||
raise HTTPException(status_code=409, detail=detail)
|
||||
|
||||
return await task_queue_service.enqueue_tasks(
|
||||
incoming_goals,
|
||||
profile=request.profile or "flash",
|
||||
@@ -112,7 +127,8 @@ async def get_status():
|
||||
latest_session_id = latest_session.get("session_id") if latest_session else None
|
||||
bg_tasks = session_repo.get_background_tasks(latest_session_id) if latest_session_id else []
|
||||
|
||||
is_running = state.is_running
|
||||
global_owner = DeviceExecutionLock.get_active_owner()
|
||||
is_running = state.is_running or global_owner is not None
|
||||
running_task = next(
|
||||
(t for t in state.queue_items if isinstance(t, dict) and t.get("status") == "running"), None
|
||||
)
|
||||
@@ -123,14 +139,23 @@ async def get_status():
|
||||
)
|
||||
|
||||
running_sid = (
|
||||
state.active_session_id
|
||||
(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 (session_repo.get_running_session_id() if is_running else None)
|
||||
)
|
||||
running_goal = state.current_goal or (running_task.get("goal") if running_task else None)
|
||||
owner_connection = (
|
||||
state.active_connections.get(str(running_sid), {}) if running_sid else {}
|
||||
)
|
||||
running_goal = (
|
||||
owner_connection.get("goal")
|
||||
or (global_owner.description if global_owner else None)
|
||||
or state.current_goal
|
||||
or (running_task.get("goal") if running_task else None)
|
||||
)
|
||||
|
||||
active_profile = state.current_profile or (
|
||||
running_task.get("profile") if running_task else None
|
||||
active_profile = owner_connection.get("profile") or state.current_profile or (
|
||||
running_task.get("profile") if running_task and not global_owner else None
|
||||
)
|
||||
if not active_profile and (running_sid or latest_session_id):
|
||||
check_sid = running_sid or latest_session_id
|
||||
@@ -151,7 +176,13 @@ async def get_status():
|
||||
"status": "paused" if is_paused else "running",
|
||||
"paused_error": state.paused_error if is_paused else None,
|
||||
"goal": running_goal,
|
||||
"pid": state.current_process.pid if state.current_process else None,
|
||||
"pid": (
|
||||
global_owner.pid
|
||||
if global_owner
|
||||
else state.current_process.pid
|
||||
if state.current_process
|
||||
else None
|
||||
),
|
||||
"session_id": running_sid,
|
||||
"background_tasks": bg_tasks,
|
||||
"queue": queue_data,
|
||||
@@ -220,6 +251,12 @@ async def stream_events(session_id: str, client: str | None = None):
|
||||
|
||||
state.add_subscriber(callback)
|
||||
yield f'event: info\ndata: {{"message": "Subscribed to session {session_id}"}}\n\n'
|
||||
if session_id not in ("all", "active"):
|
||||
for progress_event in state.get_startup_progress(session_id):
|
||||
yield (
|
||||
"event: startup_progress\n"
|
||||
f"data: {json.dumps(progress_event, default=str)}\n\n"
|
||||
)
|
||||
|
||||
shutdown_waiter = asyncio.create_task(state.shutdown_event.wait())
|
||||
queue_waiter = None
|
||||
|
||||
@@ -47,7 +47,11 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, HTMLResponse
|
||||
import uvicorn
|
||||
|
||||
from artemis.runtime import DeviceExecutionLock
|
||||
from artemis.runtime import (
|
||||
DeviceExecutionLock,
|
||||
shutdown_awake_service,
|
||||
start_awake_service,
|
||||
)
|
||||
|
||||
try:
|
||||
from admin_console.core.config import (
|
||||
@@ -104,6 +108,7 @@ async def on_startup():
|
||||
"""Startup lifecycle hooks."""
|
||||
state.is_shutting_down = False
|
||||
state.shutdown_event.clear()
|
||||
await asyncio.to_thread(start_awake_service)
|
||||
cleaned_device_locks = DeviceExecutionLock.cleanup_stale_locks()
|
||||
if cleaned_device_locks:
|
||||
print(f"[ServerStartup] Removed {cleaned_device_locks} stale device lock(s).")
|
||||
@@ -143,6 +148,9 @@ async def on_shutdown():
|
||||
|
||||
if state.current_process is not None and state.current_process.returncode is None:
|
||||
await task_queue_service._terminate_worker_process(state.current_process)
|
||||
for item in state.queue_items:
|
||||
if isinstance(item, dict):
|
||||
DeviceExecutionLock.cancel_reservation(item.get("queue_ticket"))
|
||||
DeviceExecutionLock.cleanup_stale_locks()
|
||||
state.current_process = None
|
||||
state.queue_items.clear()
|
||||
@@ -151,6 +159,7 @@ async def on_shutdown():
|
||||
|
||||
await ipc_service.stop_server()
|
||||
state.ipc_subscribers.clear()
|
||||
await asyncio.to_thread(shutdown_awake_service)
|
||||
|
||||
|
||||
# Mount modular routers
|
||||
|
||||
@@ -183,6 +183,9 @@ class IPCService:
|
||||
state.current_goal = None
|
||||
state.current_profile = None
|
||||
|
||||
if event_type == "startup_progress" and isinstance(data, dict):
|
||||
state.record_startup_progress(data)
|
||||
|
||||
for cb in list(state.ipc_subscribers):
|
||||
try:
|
||||
cb(event_type, data)
|
||||
|
||||
@@ -32,7 +32,7 @@ try:
|
||||
)
|
||||
from admin_console.core.state import state
|
||||
from admin_console.database.repositories.session_repository import session_repo
|
||||
from artemis.runtime import process_supervisor
|
||||
from artemis.runtime import DeviceExecutionLock, process_supervisor
|
||||
except ImportError:
|
||||
from apps.admin_console.core.config import (
|
||||
PAUSE_FILE,
|
||||
@@ -42,7 +42,7 @@ except ImportError:
|
||||
)
|
||||
from apps.admin_console.core.state import state
|
||||
from apps.admin_console.database.repositories.session_repository import session_repo
|
||||
from artemis.runtime import process_supervisor
|
||||
from artemis.runtime import DeviceExecutionLock, process_supervisor
|
||||
|
||||
|
||||
class TaskQueueService:
|
||||
@@ -59,6 +59,21 @@ class TaskQueueService:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def _broadcast_startup_progress(
|
||||
cls, session_id: str | None, stage: str, message: str
|
||||
) -> None:
|
||||
if not session_id:
|
||||
return
|
||||
data = {
|
||||
"session_id": str(session_id),
|
||||
"stage": stage,
|
||||
"message": message,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
state.record_startup_progress(data)
|
||||
cls._broadcast_event("startup_progress", data)
|
||||
|
||||
@classmethod
|
||||
def _get_next_pending_task(cls) -> dict[str, Any] | None:
|
||||
"""Finds and returns the first pending task from queue_items."""
|
||||
@@ -72,6 +87,13 @@ class TaskQueueService:
|
||||
"""Removes a task from queue_items by session_id."""
|
||||
if not session_id:
|
||||
return
|
||||
removed_items = [
|
||||
t
|
||||
for t in state.queue_items
|
||||
if isinstance(t, dict) and t.get("session_id") == session_id
|
||||
]
|
||||
for item in removed_items:
|
||||
DeviceExecutionLock.cancel_reservation(item.get("queue_ticket"))
|
||||
state.queue_items = [
|
||||
t
|
||||
for t in state.queue_items
|
||||
@@ -232,6 +254,10 @@ class TaskQueueService:
|
||||
state.active_session_id = sess_id
|
||||
state.was_stopped_manually = False
|
||||
|
||||
cls._broadcast_startup_progress(
|
||||
sess_id, "launching", "Starting the execution process"
|
||||
)
|
||||
|
||||
# Broadcast session_started so all connected clients know the task has started
|
||||
cls._broadcast_event(
|
||||
"session_started",
|
||||
@@ -257,6 +283,10 @@ class TaskQueueService:
|
||||
env["ARTEMIS_IPC_PORT"] = str(state.ipc_port)
|
||||
if sess_id:
|
||||
env["ARTEMIS_SESSION_ID"] = str(sess_id)
|
||||
env["ARTEMIS_TASK_INGRESS"] = "frontend"
|
||||
queue_ticket = task_item.get("queue_ticket")
|
||||
if queue_ticket:
|
||||
env[DeviceExecutionLock.QUEUE_TICKET_ENV] = str(queue_ticket)
|
||||
|
||||
cmd = [
|
||||
sys.executable,
|
||||
@@ -288,6 +318,16 @@ class TaskQueueService:
|
||||
)
|
||||
state.current_process = proc
|
||||
task_item["pid"] = proc.pid
|
||||
cls._broadcast_startup_progress(
|
||||
sess_id, "process_ready", "Execution process started"
|
||||
)
|
||||
DeviceExecutionLock.transfer_reservation(
|
||||
str(queue_ticket),
|
||||
proc.pid,
|
||||
description=f"frontend task: {goal[:120]}",
|
||||
session_id=str(sess_id) if sess_id else None,
|
||||
ingress="frontend",
|
||||
)
|
||||
if sys.platform == "win32" and isinstance(proc.stdout, asyncio.StreamReader):
|
||||
output_task = asyncio.create_task(cls._forward_worker_output(proc.stdout))
|
||||
|
||||
@@ -373,6 +413,11 @@ class TaskQueueService:
|
||||
now = time.time()
|
||||
for i, goal in enumerate(goals):
|
||||
sess_id = str(uuid.uuid4())
|
||||
queue_ticket = DeviceExecutionLock.reserve(
|
||||
description=f"frontend task: {goal[:120]}",
|
||||
session_id=sess_id,
|
||||
ingress="frontend",
|
||||
)
|
||||
task_item = {
|
||||
"session_id": sess_id,
|
||||
"goal": goal,
|
||||
@@ -382,11 +427,15 @@ class TaskQueueService:
|
||||
"locked_app_package": locked_app_package,
|
||||
"app_path": app_path,
|
||||
"status": "pending",
|
||||
"queue_ticket": queue_ticket,
|
||||
"created_at": now + i * 0.001,
|
||||
"start_time": now + i * 0.001,
|
||||
}
|
||||
state.queue_items.append(task_item)
|
||||
enqueued_tasks.append(task_item)
|
||||
cls._broadcast_startup_progress(
|
||||
sess_id, "queued", "Task received and queued"
|
||||
)
|
||||
|
||||
# Wake worker immediately
|
||||
state.wake_event.set()
|
||||
@@ -400,64 +449,119 @@ class TaskQueueService:
|
||||
|
||||
@classmethod
|
||||
def stop_tasks(cls, clear_all: bool = False) -> bool:
|
||||
"""Stops the currently executing task and optionally clears pending tasks."""
|
||||
"""Stop the one task currently controlling the global Artemis device.
|
||||
|
||||
The active lease is shared by frontend, MCP, CLI, SDK, and other UI
|
||||
processes. Pending tasks are intentionally preserved unless the legacy
|
||||
``clear_all`` option is explicitly requested, in which case only this
|
||||
frontend server's pending submissions are cleared.
|
||||
"""
|
||||
stopped = False
|
||||
stopped_session_id = state.active_session_id
|
||||
owner = DeviceExecutionLock.get_active_owner()
|
||||
owner_record_exists = DeviceExecutionLock.has_owner_record()
|
||||
owner_pid = owner.pid if owner else None
|
||||
local_pid = getattr(state.current_process, "pid", None)
|
||||
is_local_owner = bool(owner_pid and local_pid and owner_pid == local_pid)
|
||||
|
||||
if not stopped_session_id:
|
||||
running_item = next(
|
||||
(
|
||||
t
|
||||
for t in state.queue_items
|
||||
if isinstance(t, dict) and t.get("status") == "running"
|
||||
),
|
||||
None,
|
||||
)
|
||||
if running_item:
|
||||
stopped_session_id = running_item.get("session_id")
|
||||
elif state.queue_items and not clear_all:
|
||||
stopped_session_id = state.queue_items[0].get("session_id")
|
||||
|
||||
if stopped_session_id:
|
||||
getattr(state, "cancelled_session_ids", set()).add(str(stopped_session_id))
|
||||
for t in state.queue_items:
|
||||
if isinstance(t, dict) and t.get("status") == "running" and t.get("session_id"):
|
||||
getattr(state, "cancelled_session_ids", set()).add(str(t["session_id"]))
|
||||
running_item = next(
|
||||
(
|
||||
item
|
||||
for item in state.queue_items
|
||||
if isinstance(item, dict) and item.get("status") == "running"
|
||||
),
|
||||
None,
|
||||
)
|
||||
local_item = running_item or next(
|
||||
(
|
||||
item
|
||||
for item in state.queue_items
|
||||
if isinstance(item, dict) and item.get("status") == "pending"
|
||||
),
|
||||
None,
|
||||
)
|
||||
stopped_session_id = (
|
||||
owner.session_id
|
||||
if owner and owner.session_id
|
||||
else state.active_session_id
|
||||
if owner
|
||||
else local_item.get("session_id")
|
||||
if local_item
|
||||
else None
|
||||
)
|
||||
|
||||
if clear_all:
|
||||
for item in state.queue_items:
|
||||
if isinstance(item, dict) and item.get("status") != "running":
|
||||
DeviceExecutionLock.cancel_reservation(item.get("queue_ticket"))
|
||||
state.clear_queue()
|
||||
stopped = True
|
||||
|
||||
state.was_stopped_manually = True
|
||||
if state.current_process:
|
||||
pid = getattr(state.current_process, "pid", None)
|
||||
if pid:
|
||||
if owner and DeviceExecutionLock.is_active_owner(owner):
|
||||
stopped = process_supervisor.terminate_tree_verified(
|
||||
owner.pid,
|
||||
owner.process_created_at,
|
||||
)
|
||||
DeviceExecutionLock.cleanup_stale_locks()
|
||||
elif owner is None and owner_record_exists:
|
||||
# Never fall back to a frontend PID while another process has an
|
||||
# owner record that is still being published or cannot be parsed.
|
||||
return False
|
||||
elif owner is None and state.current_process:
|
||||
# The frontend worker can be stopped during its short initialization
|
||||
# window before Agent acquires the global device lease.
|
||||
state.was_stopped_manually = True
|
||||
if local_pid:
|
||||
try:
|
||||
process_supervisor.terminate_tree(pid)
|
||||
stopped = process_supervisor.terminate_tree(local_pid)
|
||||
except Exception:
|
||||
pass
|
||||
stopped = False
|
||||
try:
|
||||
state.current_process.kill()
|
||||
stopped = True
|
||||
except Exception:
|
||||
pass
|
||||
# A dead worker is already stopped; cleanup must still be treated
|
||||
# as a successful, idempotent stop operation.
|
||||
stopped = True
|
||||
is_local_owner = True
|
||||
elif owner is None and local_item:
|
||||
# Cancel a frontend submission before its worker has started. This
|
||||
# does not touch pending reservations created by other ingresses.
|
||||
DeviceExecutionLock.cancel_reservation(local_item.get("queue_ticket"))
|
||||
stopped = True
|
||||
is_local_owner = True
|
||||
|
||||
for _, conn_info in list(state.active_connections.items()):
|
||||
pid = conn_info.get("pid")
|
||||
if pid:
|
||||
try:
|
||||
process_supervisor.terminate_tree(pid)
|
||||
stopped = True
|
||||
except Exception as e:
|
||||
print(f"Failed to kill external process {pid}: {e}")
|
||||
if not stopped:
|
||||
if clear_all:
|
||||
state.wake_event.set()
|
||||
return clear_all
|
||||
|
||||
# Always reset process and active states cleanly
|
||||
state.current_process = None
|
||||
state.active_connections.clear()
|
||||
session_repo.mark_all_running_cancelled()
|
||||
if is_local_owner:
|
||||
state.was_stopped_manually = True
|
||||
state.current_process = None
|
||||
if stopped_session_id:
|
||||
state.cancelled_session_ids.add(str(stopped_session_id))
|
||||
|
||||
for session_id, conn_info in list(state.active_connections.items()):
|
||||
if (stopped_session_id and str(session_id) == str(stopped_session_id)) or (
|
||||
owner_pid and conn_info.get("pid") == owner_pid
|
||||
):
|
||||
state.active_connections.pop(session_id, None)
|
||||
|
||||
# Broadcast session_ended event so all SSE streams and subscribers update immediately
|
||||
if stopped_session_id:
|
||||
session_repo.update_session_status(
|
||||
str(stopped_session_id), "cancelled", time.time()
|
||||
)
|
||||
if owner and owner.ingress == "mcp":
|
||||
try:
|
||||
from mcp_server.utils import trace_store
|
||||
|
||||
trace_store.update_trace_status(
|
||||
str(stopped_session_id),
|
||||
"cancelled",
|
||||
error="Task stopped from the Artemis frontend.",
|
||||
)
|
||||
except Exception as exc:
|
||||
print(f"Failed to update MCP cancellation status: {exc}")
|
||||
cls._broadcast_event(
|
||||
"session_ended",
|
||||
{
|
||||
@@ -466,27 +570,37 @@ class TaskQueueService:
|
||||
"was_stopped_manually": True,
|
||||
},
|
||||
)
|
||||
stopped = True
|
||||
|
||||
state.active_session_id = None
|
||||
state.current_goal = None
|
||||
state.current_profile = None
|
||||
if state.active_session_id and (
|
||||
not stopped_session_id
|
||||
or str(state.active_session_id) == str(stopped_session_id)
|
||||
):
|
||||
state.active_session_id = None
|
||||
state.current_goal = None
|
||||
state.current_profile = None
|
||||
|
||||
# Ensure any running or stopped task is removed from queue_items so worker_loop is unblocked
|
||||
if clear_all:
|
||||
state.queue_items = []
|
||||
else:
|
||||
if is_local_owner and stopped_session_id:
|
||||
stopped_item = next(
|
||||
(
|
||||
item
|
||||
for item in state.queue_items
|
||||
if isinstance(item, dict)
|
||||
and str(item.get("session_id")) == str(stopped_session_id)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if stopped_item:
|
||||
DeviceExecutionLock.cancel_reservation(stopped_item.get("queue_ticket"))
|
||||
state.queue_items = [
|
||||
t
|
||||
for t in state.queue_items
|
||||
item
|
||||
for item in state.queue_items
|
||||
if not (
|
||||
isinstance(t, dict)
|
||||
and (
|
||||
t.get("status") == "running"
|
||||
or (stopped_session_id and t.get("session_id") == stopped_session_id)
|
||||
)
|
||||
isinstance(item, dict)
|
||||
and str(item.get("session_id")) == str(stopped_session_id)
|
||||
)
|
||||
]
|
||||
if clear_all:
|
||||
state.queue_items = []
|
||||
|
||||
if PAUSE_FILE.exists():
|
||||
try:
|
||||
@@ -494,11 +608,9 @@ class TaskQueueService:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Wake up worker to process the next pending task or transition cleanly
|
||||
cls.ensure_worker_running()
|
||||
state.wake_event.set()
|
||||
|
||||
return stopped or True
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def resume_task(cls) -> bool:
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { StartupProgressEvent } from '../../services/agent.service';
|
||||
import { buildStartupWorkItems } from './agent-stream.component';
|
||||
|
||||
describe('startup Work block', () => {
|
||||
it('shows only the three device preparation operations', () => {
|
||||
const events: StartupProgressEvent[] = [
|
||||
{ stage: 'submitting', message: 'Submitting the task', timestamp: 100 },
|
||||
{ stage: 'queued', message: 'Task received and queued', timestamp: 100.9 },
|
||||
{ stage: 'launching', message: 'Starting the execution process', timestamp: 101 },
|
||||
{ stage: 'configuration', message: 'Loading the run configuration', timestamp: 103.1 },
|
||||
{ stage: 'device_check', message: 'Checking the Android device', timestamp: 103.2 },
|
||||
{ stage: 'device_ready', message: 'Android device connected', timestamp: 123 },
|
||||
{ stage: 'model_warmup', message: 'Warming the model connection', timestamp: 123 },
|
||||
{ stage: 'uiautomator', message: 'Connecting to UI Automator', timestamp: 124 },
|
||||
{ stage: 'uiautomator_ready', message: 'UI Automator is ready', timestamp: 128 },
|
||||
{ stage: 'environment', message: 'Preparing the device environment', timestamp: 128 },
|
||||
{ stage: 'environment_ready', message: 'Device environment is ready', timestamp: 131 }
|
||||
];
|
||||
|
||||
const items = buildStartupWorkItems(events, 131, false, true);
|
||||
|
||||
expect(items.map((item) => item.stage)).toEqual([
|
||||
'device_ready',
|
||||
'uiautomator_ready',
|
||||
'environment_ready'
|
||||
]);
|
||||
expect(items.map((item) => item.elapsed)).toEqual(['20s', '4.0s', '3.0s']);
|
||||
expect(items.every((item) => !item.isActive)).toBeTrue();
|
||||
});
|
||||
|
||||
it('keeps the current preparation operation live without exposing first-action text', () => {
|
||||
const events: StartupProgressEvent[] = [
|
||||
{ stage: 'device_check', message: 'Checking the Android device', timestamp: 10 }
|
||||
];
|
||||
|
||||
const [item] = buildStartupWorkItems(events, 11.4, false, true);
|
||||
|
||||
expect(item.message).toBe('Checking the Android device');
|
||||
expect(item.elapsed).toBe('1.4s');
|
||||
expect(item.isActive).toBeTrue();
|
||||
});
|
||||
|
||||
it('uses old first_response events only as a hidden completion boundary', () => {
|
||||
const events: StartupProgressEvent[] = [
|
||||
{ stage: 'environment', message: 'Preparing the device environment', timestamp: 20 },
|
||||
{ stage: 'first_response', message: 'Generating the first action', timestamp: 23 }
|
||||
];
|
||||
|
||||
const [item] = buildStartupWorkItems(events, 24, false, true);
|
||||
|
||||
expect(item.message).toBe('Device environment is ready');
|
||||
expect(item.elapsed).toBe('3.0s');
|
||||
expect(item.isActive).toBeFalse();
|
||||
});
|
||||
});
|
||||
@@ -290,40 +290,43 @@
|
||||
|
||||
<!-- Event Logs Section -->
|
||||
<main class="stream-logs-content" (scroll)="onStreamScroll($event)">
|
||||
<!-- Keep recovery available even when a persisted failure trace is absent. -->
|
||||
<div class="paused-task-banner" *ngIf="isViewingPausedTask()" role="status">
|
||||
<div class="paused-task-message">
|
||||
<span class="material-symbols-outlined paused-task-icon">pause_circle</span>
|
||||
<div>
|
||||
<strong>Task paused</strong>
|
||||
<span>The AI request failed. Resume the task to try again.</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-resume-task"
|
||||
(click)="resumePausedTask($event)"
|
||||
title="Resume the paused task"
|
||||
aria-label="Resume the paused task"
|
||||
>
|
||||
<span class="material-symbols-outlined">play_arrow</span>
|
||||
Resume
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div *ngIf="consolidatedBlocks().length === 0 && !showPlanningLoader()" class="empty-state">
|
||||
<div *ngIf="consolidatedBlocks().length === 0 && !showPlanningLoader() && startupWorkItems().length === 0" class="empty-state">
|
||||
<div class="empty-icon"><span class="material-symbols-outlined">sensors</span></div>
|
||||
<h3>No Stream Activity Selected</h3>
|
||||
<p>Please select a session from the Task List on the right to view its real-time log payloads.</p>
|
||||
</div>
|
||||
|
||||
<!-- The phase-based session logs list -->
|
||||
<div class="logs-list-container" *ngIf="phases().length > 0 || showPlanningLoader()">
|
||||
<div class="logs-list-container" *ngIf="phases().length > 0 || showPlanningLoader() || startupWorkItems().length > 0">
|
||||
<div class="phase-container startup-phase" *ngIf="startupWorkItems().length > 0" aria-live="polite">
|
||||
<div class="phase-header first-phase">
|
||||
<span class="phase-worked-time">Worked for {{ startupPreparationDuration() }}s</span>
|
||||
</div>
|
||||
<div class="phase-body" aria-label="Task startup progress">
|
||||
<div class="block-item type-stream">
|
||||
<div class="step-body">
|
||||
<div class="step-actions-container">
|
||||
<div class="llm-retry-notes">
|
||||
<div class="llm-retry-note" *ngFor="let item of startupWorkItems()">
|
||||
<span class="stream-status-dot" [class.streaming]="item.isActive"></span>
|
||||
<div class="llm-retry-note-text">
|
||||
<span class="llm-retry-status-line">
|
||||
{{ item.message }} · {{ item.isActive ? 'Waiting ' : 'Waited ' }}{{ item.elapsed }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="phase-container" *ngFor="let phase of phases(); let first = first; trackBy: trackPhase">
|
||||
<div
|
||||
class="phase-header"
|
||||
*ngIf="hasVisibleBlocks(phase)"
|
||||
[class.first-phase]="first"
|
||||
[class.first-phase]="first && startupWorkItems().length === 0"
|
||||
>
|
||||
<span class="phase-worked-time">Worked for {{ phase.durationSeconds }}s</span>
|
||||
<span class="phase-token-usage" *ngIf="phase.tokens" [title]="getTokenTooltip(phase)">
|
||||
@@ -558,11 +561,11 @@
|
||||
</div>
|
||||
|
||||
<!-- Failed LLM Calls (Permanently maintain Action Card format in red) -->
|
||||
<div class="step-android-action-card tool-action-card is-failed"
|
||||
[class.has-details]="hasLLMFailureRetryEntries(item.data)"
|
||||
[class.is-expanded]="isActionExpanded('llm-failure-' + block.id + '-' + itemIdx)"
|
||||
(click)="hasLLMFailureRetryEntries(item.data) && toggleActionExpanded('llm-failure-' + block.id + '-' + itemIdx, $event)"
|
||||
*ngIf="isDisplayableLLMFailure(item.data)">
|
||||
<div class="llm-failure-stack" *ngIf="isDisplayableLLMFailure(item.data)">
|
||||
<div class="step-android-action-card tool-action-card is-failed"
|
||||
[class.has-details]="hasLLMFailureRetryEntries(item.data)"
|
||||
[class.is-expanded]="isActionExpanded('llm-failure-' + block.id + '-' + itemIdx)"
|
||||
(click)="hasLLMFailureRetryEntries(item.data) && toggleActionExpanded('llm-failure-' + block.id + '-' + itemIdx, $event)">
|
||||
<div class="action-card-header llm-failure-header">
|
||||
<span class="material-symbols-outlined action-icon is-failed">error</span>
|
||||
<span class="action-title">LLM Request Failed</span>
|
||||
@@ -576,24 +579,17 @@
|
||||
Failed
|
||||
</span>
|
||||
</ng-template>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-resume-llm"
|
||||
*ngIf="isTerminalLLMFailure(item.data)"
|
||||
(click)="resumePausedTask($event)"
|
||||
title="Resume the paused task"
|
||||
aria-label="Resume the paused task"
|
||||
>
|
||||
<span class="material-symbols-outlined">play_arrow</span>
|
||||
Resume
|
||||
</button>
|
||||
<span class="action-expand-icon material-symbols-outlined" *ngIf="hasLLMFailureRetryEntries(item.data)">
|
||||
{{ isActionExpanded('llm-failure-' + block.id + '-' + itemIdx) ? 'expand_less' : 'expand_more' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="action-card-body">
|
||||
<div class="action-detail-row">
|
||||
<span class="detail-label">Reason:</span>
|
||||
<span class="detail-label">What happened:</span>
|
||||
<span class="detail-value">The AI request could not be completed after automatic retries.</span>
|
||||
</div>
|
||||
<div class="action-detail-row">
|
||||
<span class="detail-label">Failure reason:</span>
|
||||
<span class="detail-value">{{ getLLMErrorText(item.data) }}</span>
|
||||
</div>
|
||||
<div class="action-detail-row" *ngIf="getLLMFailureWaited(item.data) as waited">
|
||||
@@ -611,6 +607,22 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="llm-resume-row" *ngIf="isTerminalLLMFailure(item.data)">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-continue-task"
|
||||
(click)="resumePausedTask($event)"
|
||||
title="Continue the paused task"
|
||||
aria-label="Continue the paused task"
|
||||
>
|
||||
<span class="material-symbols-outlined continue-icon">play_arrow</span>
|
||||
<span>Continue task</span>
|
||||
<span class="material-symbols-outlined continue-arrow">arrow_forward</span>
|
||||
</button>
|
||||
<span class="llm-resume-hint">Task paused · no steps are running</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Generic Tool Calls (Text format for note tools, video analyzer, and other sub-agents) -->
|
||||
@@ -842,7 +854,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Planning Loader -->
|
||||
<div class="planning-loader-container" *ngIf="showPlanningLoader()">
|
||||
<div class="planning-loader-container" *ngIf="showPlanningLoader() && (startupWorkItems().length === 0 || startupPreparationIsComplete() || consolidatedBlocks().length > 0)">
|
||||
<div class="planning-loader">
|
||||
<span class="material-symbols-outlined spin-animation">progress_activity</span>
|
||||
<span class="loader-text">{{ currentPlanningText() }}</span>
|
||||
|
||||
@@ -1060,99 +1060,6 @@ $uniform-gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.paused-task-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 12px;
|
||||
background: #fff7ed;
|
||||
border: 1px solid #fdba74;
|
||||
border-radius: 10px;
|
||||
color: #9a3412;
|
||||
box-shadow: 0 1px 3px rgba(154, 52, 18, 0.08);
|
||||
|
||||
.paused-task-message {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
.paused-task-icon {
|
||||
flex: 0 0 auto;
|
||||
font-size: 21px;
|
||||
color: #ea580c;
|
||||
}
|
||||
|
||||
div {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
span:not(.paused-task-icon) {
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
color: #c2410c;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-resume-task {
|
||||
flex: 0 0 auto;
|
||||
min-height: 32px;
|
||||
padding: 6px 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
border: 1px solid #2563eb;
|
||||
border-radius: 8px;
|
||||
background: #2563eb;
|
||||
color: #ffffff;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 5px rgba(37, 99, 235, 0.2);
|
||||
transition: background 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
|
||||
|
||||
.material-symbols-outlined {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #1d4ed8;
|
||||
box-shadow: 0 3px 8px rgba(37, 99, 235, 0.28);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid rgba(37, 99, 235, 0.35);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
|
||||
.btn-resume-task {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.logs-list-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1203,8 +1110,11 @@ $uniform-gap: 8px;
|
||||
.phase-header {
|
||||
font-size: 12px;
|
||||
font-weight: normal;
|
||||
line-height: 1.5;
|
||||
min-height: 20px;
|
||||
box-sizing: border-box;
|
||||
color: #71717a;
|
||||
margin-top: 14px;
|
||||
margin-top: $uniform-gap * 2;
|
||||
margin-bottom: 8px;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
@@ -2230,6 +2140,111 @@ $uniform-gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.llm-failure-stack {
|
||||
width: 380px;
|
||||
max-width: 100%;
|
||||
margin-left: -19px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
|
||||
.step-android-action-card {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.llm-resume-row {
|
||||
min-height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn-continue-task {
|
||||
min-height: 36px;
|
||||
padding: 7px 12px 7px 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #1d4ed8;
|
||||
border-radius: 9px;
|
||||
background: #2563eb;
|
||||
color: #ffffff;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 7px rgba(37, 99, 235, 0.22);
|
||||
transition: background 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
|
||||
|
||||
.continue-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: -2px 0 -2px -2px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.continue-arrow {
|
||||
font-size: 15px;
|
||||
opacity: 0.72;
|
||||
transition: transform 0.18s ease, opacity 0.18s ease;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #1d4ed8;
|
||||
border-color: #1e40af;
|
||||
box-shadow: 0 4px 11px rgba(37, 99, 235, 0.28);
|
||||
transform: translateY(-1px);
|
||||
|
||||
.continue-arrow {
|
||||
opacity: 1;
|
||||
transform: translateX(2px);
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
box-shadow: 0 1px 4px rgba(37, 99, 235, 0.2);
|
||||
transform: translateY(0) scale(0.98);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 3px solid rgba(59, 130, 246, 0.25);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.llm-resume-hint {
|
||||
color: #94a3b8;
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.llm-resume-row {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-continue-task {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.llm-resume-hint {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.step-android-action-card {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
@@ -2488,51 +2503,6 @@ $uniform-gap: 8px;
|
||||
|
||||
}
|
||||
|
||||
&.llm-failure-header {
|
||||
flex-wrap: wrap;
|
||||
|
||||
.btn-resume-llm {
|
||||
flex: 0 0 auto;
|
||||
min-height: 26px;
|
||||
padding: 3px 9px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 7px;
|
||||
background: #ffffff;
|
||||
color: #2563eb;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
transition: background 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
|
||||
|
||||
.material-symbols-outlined {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #eff6ff;
|
||||
border-color: #93c5fd;
|
||||
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.12);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid rgba(37, 99, 235, 0.35);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.action-expand-icon {
|
||||
font-size: 18px;
|
||||
color: #94a3b8;
|
||||
|
||||
@@ -19,7 +19,7 @@ import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { AgentService } from '../../services/agent.service';
|
||||
import { AgentService, StartupProgressEvent } from '../../services/agent.service';
|
||||
import { Session } from '../../core/models/session.model';
|
||||
import { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote } from '../../core/models/markdown.model';
|
||||
import { StepBlock, PhaseBlock, StepEvent, ActionParam, CheckerResult } from '../../core/models/stream.model';
|
||||
@@ -43,6 +43,83 @@ export const PLANNING_LOADER_PHRASES: string[] = [
|
||||
'Strategizing tactical moves...'
|
||||
];
|
||||
|
||||
export interface StartupWorkItem extends StartupProgressEvent {
|
||||
isActive: boolean;
|
||||
elapsed: string;
|
||||
}
|
||||
|
||||
interface StartupWorkStage {
|
||||
started: string;
|
||||
completed: string;
|
||||
completedMessage: string;
|
||||
}
|
||||
|
||||
const STARTUP_WORK_STAGES: StartupWorkStage[] = [
|
||||
{
|
||||
started: 'device_check',
|
||||
completed: 'device_ready',
|
||||
completedMessage: 'Android device connected'
|
||||
},
|
||||
{
|
||||
started: 'uiautomator',
|
||||
completed: 'uiautomator_ready',
|
||||
completedMessage: 'UI Automator is ready'
|
||||
},
|
||||
{
|
||||
started: 'environment',
|
||||
completed: 'environment_ready',
|
||||
completedMessage: 'Device environment is ready'
|
||||
}
|
||||
];
|
||||
|
||||
function formatStartupElapsed(seconds: number): string {
|
||||
const safeSeconds = Math.max(0, seconds);
|
||||
return safeSeconds < 10
|
||||
? `${safeSeconds.toFixed(1)}s`
|
||||
: `${Math.round(safeSeconds)}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse noisy process-level startup events into the three device preparation
|
||||
* operations that are useful to someone watching a run.
|
||||
*/
|
||||
export function buildStartupWorkItems(
|
||||
events: StartupProgressEvent[],
|
||||
nowSeconds: number,
|
||||
executionHasOutput: boolean,
|
||||
agentIsActive: boolean
|
||||
): StartupWorkItem[] {
|
||||
const byStage = new Map(events.map((event) => [event.stage, event]));
|
||||
const firstResponse = byStage.get('first_response');
|
||||
|
||||
return STARTUP_WORK_STAGES.flatMap((stage, stageIndex) => {
|
||||
const started = byStage.get(stage.started);
|
||||
const explicitlyCompleted = byStage.get(stage.completed);
|
||||
if (!started && !explicitlyCompleted) return [];
|
||||
|
||||
const nextStageStarted = STARTUP_WORK_STAGES
|
||||
.slice(stageIndex + 1)
|
||||
.map((nextStage) => byStage.get(nextStage.started) || byStage.get(nextStage.completed))
|
||||
.find((event): event is StartupProgressEvent => Boolean(event));
|
||||
const inferredCompletion = stage.started === 'environment'
|
||||
? firstResponse
|
||||
: nextStageStarted;
|
||||
const completed = explicitlyCompleted || inferredCompletion;
|
||||
const isActive = !completed && !executionHasOutput && agentIsActive;
|
||||
const startTimestamp = started?.timestamp || explicitlyCompleted?.timestamp || nowSeconds;
|
||||
const endTimestamp = completed?.timestamp
|
||||
|| (isActive ? nowSeconds : events[events.length - 1]?.timestamp || startTimestamp);
|
||||
|
||||
return [{
|
||||
...(explicitlyCompleted || started!),
|
||||
message: explicitlyCompleted?.message
|
||||
|| (completed ? stage.completedMessage : started!.message),
|
||||
isActive,
|
||||
elapsed: formatStartupElapsed(endTimestamp - startTimestamp)
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
import {
|
||||
parseNote,
|
||||
parseNoteLines,
|
||||
@@ -232,7 +309,10 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
});
|
||||
|
||||
public isViewingPausedTask = computed(() => {
|
||||
if (!this.agentService.isPaused()) return false;
|
||||
// A live task_paused event and the polled runner status must agree before
|
||||
// recovery controls are shown. This prevents a stale failure trace from
|
||||
// offering Resume while the active task is still reported as running.
|
||||
if (!this.agentService.isPaused() || this.agentService.agentStatus() !== 'paused') return false;
|
||||
|
||||
const currentSessionId = this.agentService.currentSessionId();
|
||||
const pausedSessionId = this.agentService.runningSessionId();
|
||||
@@ -253,8 +333,44 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
return groupBlocksToPhases(this.consolidatedBlocks(), currentSession?.start_time || 0);
|
||||
});
|
||||
|
||||
public startupWorkItems = computed(() => {
|
||||
this.retryClock();
|
||||
const events = this.agentService.currentStartupProgress();
|
||||
const agentIsActive = ['running', 'paused'].includes(this.agentService.agentStatus());
|
||||
return buildStartupWorkItems(
|
||||
events,
|
||||
Date.now() / 1000,
|
||||
this.consolidatedBlocks().length > 0,
|
||||
agentIsActive
|
||||
);
|
||||
});
|
||||
|
||||
public startupPreparationIsRunning = computed(() => {
|
||||
return this.startupWorkItems().some((item) => item.isActive);
|
||||
});
|
||||
|
||||
public startupPreparationIsComplete = computed(() => {
|
||||
const items = this.startupWorkItems();
|
||||
return items.length > 0 && !items.some((item) => item.isActive);
|
||||
});
|
||||
|
||||
public startupPreparationDuration = computed(() => {
|
||||
const items = this.startupWorkItems();
|
||||
if (items.length === 0) return 0;
|
||||
|
||||
const events = this.agentService.currentStartupProgress();
|
||||
const startedAt = events[0]?.timestamp || items[0].timestamp;
|
||||
const completedAt = events.find((event) => event.stage === 'environment_ready')?.timestamp
|
||||
|| events.find((event) => event.stage === 'first_response')?.timestamp;
|
||||
const endedAt = this.startupPreparationIsRunning()
|
||||
? Date.now() / 1000
|
||||
: completedAt || Math.max(...items.map((item) => item.timestamp));
|
||||
|
||||
return Math.max(1, Math.round(endedAt - startedAt));
|
||||
});
|
||||
|
||||
private retryablePauseTrace = computed<any | null>(() => {
|
||||
if (!this.agentService.isPaused()) return null;
|
||||
if (!this.isViewingPausedTask()) return null;
|
||||
|
||||
const blocks = this.consolidatedBlocks();
|
||||
for (let blockIndex = blocks.length - 1; blockIndex >= 0; blockIndex--) {
|
||||
@@ -263,11 +379,9 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
|
||||
for (let toolIndex = tools.length - 1; toolIndex >= 0; toolIndex--) {
|
||||
const tool = tools[toolIndex];
|
||||
if (
|
||||
tool?.type === 'llm_call'
|
||||
&& tool?.status === 'failed'
|
||||
&& tool?.payload?.pause === true
|
||||
) {
|
||||
// Older persisted traces used the llm_pause name without adding the
|
||||
// newer payload.pause marker. Treat both contracts consistently.
|
||||
if (this.isDisplayableLLMFailure(tool)) {
|
||||
return tool;
|
||||
}
|
||||
}
|
||||
@@ -873,10 +987,12 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
}
|
||||
|
||||
public getLLMErrorText(tool: any): string {
|
||||
if (!tool) return 'Unknown error';
|
||||
const noDetails = 'The AI provider did not return error details after the request failed.';
|
||||
if (!tool) return noDetails;
|
||||
const rawError = tool.payload?.error || tool.error;
|
||||
if (!rawError) return 'Unknown error';
|
||||
return this.cleanErrorMessage(rawError);
|
||||
if (!rawError) return noDetails;
|
||||
const cleaned = this.cleanErrorMessage(rawError);
|
||||
return cleaned === 'Unknown error' ? noDetails : cleaned;
|
||||
}
|
||||
|
||||
public isLLMRetry(tool: any): boolean {
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
import { Component, inject, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { AgentService } from '../../services/agent.service';
|
||||
import { Session } from '../../core/models/session.model';
|
||||
import { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote } from '../../core/models/markdown.model';
|
||||
@@ -34,7 +33,6 @@ export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote };
|
||||
})
|
||||
export class ChatInterfaceComponent {
|
||||
public agentService = inject(AgentService);
|
||||
private http = inject(HttpClient);
|
||||
|
||||
public taskInput: string = '';
|
||||
public isSubmitting: boolean = false;
|
||||
@@ -79,23 +77,10 @@ export class ChatInterfaceComponent {
|
||||
this.isSubmitting = true;
|
||||
this.errorMessage = null;
|
||||
|
||||
this.agentService.clearUserPinnedSession();
|
||||
this.http.post<any>('/api/run', { goal }).subscribe({
|
||||
this.agentService.runTask(goal).subscribe({
|
||||
next: (res) => {
|
||||
this.taskInput = '';
|
||||
this.isSubmitting = false;
|
||||
if (res && res.tasks && res.tasks.length > 0) {
|
||||
const firstTask = res.tasks[0];
|
||||
if (firstTask && firstTask.session_id) {
|
||||
// Only auto-select immediately if no task is currently executing/running
|
||||
const isCurrentlyRunning =
|
||||
this.agentService.agentStatus() === 'running' ||
|
||||
this.agentService.sessions().some((s) => s.status === 'running');
|
||||
if (!isCurrentlyRunning) {
|
||||
this.agentService.selectSession(firstTask.session_id, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fetch status to refresh sessions list and select new session
|
||||
this.agentService.fetchStatus();
|
||||
},
|
||||
|
||||
@@ -31,6 +31,7 @@ export interface DeviceInfo {
|
||||
product: string | null;
|
||||
android_version: string | null;
|
||||
screen_resolution: string | null;
|
||||
is_locked: boolean | null;
|
||||
is_emulator: boolean;
|
||||
installed_packages?: string[];
|
||||
}
|
||||
|
||||
@@ -937,7 +937,7 @@
|
||||
}
|
||||
|
||||
<!-- STATE B-3: NO DEVICE DETECTED / READY TO CONNECT GUIDE -->
|
||||
@if (!isDeviceReady() && !isEmulatorLaunching() && adbProbe()?.summary !== 'Device Booting' && adbProbe()?.summary !== 'Device Unauthorized' && adbProbe()?.status !== 'fail' && emulatorLaunchState()?.status !== 'failed') {
|
||||
@if (!isDeviceReady() && !isEmulatorLaunching() && adbProbe()?.summary !== 'Device Booting' && adbProbe()?.summary !== 'Device Unauthorized' && adbProbe()?.summary !== 'Device Locked' && adbProbe()?.summary !== 'Lock State Unknown' && adbProbe()?.status !== 'fail' && emulatorLaunchState()?.status !== 'failed') {
|
||||
<div class="foolproof-guide-container">
|
||||
<!-- Method Selection Nav Cards -->
|
||||
<div class="connection-methods-nav">
|
||||
@@ -1357,6 +1357,27 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- STATE C: CONNECTED DEVICE IS LOCKED / LOCK STATE UNVERIFIED -->
|
||||
@if (adbProbe()?.status === 'warn' && (adbProbe()?.summary === 'Device Locked' || adbProbe()?.summary === 'Lock State Unknown')) {
|
||||
<div class="device-locked-panel">
|
||||
<div class="device-locked-card">
|
||||
<span class="material-symbols-outlined alert-icon">lock</span>
|
||||
<div class="locked-details">
|
||||
<div class="locked-title">
|
||||
{{ adbProbe()?.summary === 'Device Locked' ? 'Device Is Locked' : 'Unable to Verify Lock State' }}
|
||||
</div>
|
||||
<div class="locked-desc">
|
||||
{{ adbProbe()?.description }}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn-check-unlock" [disabled]="isRefreshingDiagnostics()" (click)="refreshReadiness()">
|
||||
<span class="material-symbols-outlined" [class.spinning]="isRefreshingDiagnostics()">refresh</span>
|
||||
<span>{{ isRefreshingDiagnostics() ? 'Checking...' : 'Check Again' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- STATE C: UNAUTHORIZED DEVICE -->
|
||||
@if (adbProbe()?.status === 'warn' && adbProbe()?.summary === 'Device Unauthorized') {
|
||||
<div class="unauthorized-panel">
|
||||
@@ -1478,8 +1499,12 @@
|
||||
<!-- Prerequisites Drop Alert if any blocker probe is not passing -->
|
||||
@if (!isReady()) {
|
||||
<div class="launcher-warning-bar" (click)="setTab('diagnostics')">
|
||||
<span class="material-symbols-outlined warn-icon">warning</span>
|
||||
<span>Prerequisites incomplete: runtime, credentials, or Android device is not ready. Click here to open Setup Guide.</span>
|
||||
<span class="material-symbols-outlined warn-icon">{{ adbProbe()?.summary === 'Device Locked' ? 'lock' : 'warning' }}</span>
|
||||
<span>
|
||||
{{ adbProbe()?.summary === 'Device Locked'
|
||||
? 'Android device is locked. Unlock it and enter the home screen before running a task.'
|
||||
: 'Prerequisites incomplete: runtime, credentials, or Android device is not ready. Click here to open Setup Guide.' }}
|
||||
</span>
|
||||
<span class="material-symbols-outlined arrow">arrow_forward</span>
|
||||
</div>
|
||||
}
|
||||
@@ -1584,7 +1609,8 @@
|
||||
type="button"
|
||||
class="btn-run-composer"
|
||||
[class.btn-run-pro]="selectedProfile() === 'pro'"
|
||||
[disabled]="!taskGoal().trim() || isSubmitting()"
|
||||
[disabled]="!taskGoal().trim() || isSubmitting() || !isReady()"
|
||||
[title]="!isReady() ? (adbProbe()?.summary === 'Device Locked' ? 'Unlock the Android device before running' : 'Complete System Setup before running') : 'Run task'"
|
||||
(click)="runTask()"
|
||||
>
|
||||
@if (isSubmitting()) {
|
||||
|
||||
@@ -3089,9 +3089,11 @@ $text-light: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
/* State C: Unauthorized Panel */
|
||||
.unauthorized-panel {
|
||||
.unauthorized-card {
|
||||
/* State C: Locked / Unauthorized Device Panels */
|
||||
.unauthorized-panel,
|
||||
.device-locked-panel {
|
||||
.unauthorized-card,
|
||||
.device-locked-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
@@ -3106,25 +3108,29 @@ $text-light: #94a3b8;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.unauth-details {
|
||||
.unauth-details,
|
||||
.locked-details {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.unauth-title {
|
||||
.unauth-title,
|
||||
.locked-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: $amber-dark;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.unauth-desc {
|
||||
.unauth-desc,
|
||||
.locked-desc {
|
||||
font-size: 11.5px;
|
||||
color: #78350f;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-retry-auth {
|
||||
.btn-retry-auth,
|
||||
.btn-check-unlock {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
@@ -3144,7 +3150,7 @@ $text-light: #94a3b8;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&:hover {
|
||||
&:hover:not(:disabled) {
|
||||
background: rgba(253, 230, 138, 0.95);
|
||||
border-color: $amber;
|
||||
transform: translateY(-0.5px);
|
||||
@@ -3154,6 +3160,11 @@ $text-light: #94a3b8;
|
||||
&:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.65;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
import { Component, HostListener, inject, computed, signal, ViewChild, ElementRef, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { AgentStreamComponent } from '../../components/agent-stream/agent-stream.component';
|
||||
import { ChatInterfaceComponent } from '../../components/chat-interface/chat-interface.component';
|
||||
import { FloatingVideoPlayerComponent } from '../../components/floating-video-player/floating-video-player.component';
|
||||
@@ -38,7 +37,6 @@ import { AgentService } from '../../services/agent.service';
|
||||
})
|
||||
export class WorkspaceComponent implements OnInit {
|
||||
public agentService = inject(AgentService);
|
||||
private http = inject(HttpClient);
|
||||
|
||||
// Default right panel width to 1/3 of the screen (or 450px as fallback)
|
||||
public rightPanelWidth = typeof window !== 'undefined' ? Math.round(window.innerWidth / 3) : 450;
|
||||
@@ -197,25 +195,13 @@ export class WorkspaceComponent implements OnInit {
|
||||
}
|
||||
this.isInputFocused.set(false);
|
||||
|
||||
this.agentService.clearUserPinnedSession();
|
||||
this.http.post<any>('/api/run', { goal, profile: this.selectedProfile() }).subscribe({
|
||||
this.agentService.runTask(goal, this.selectedProfile()).subscribe({
|
||||
next: (res) => {
|
||||
this.taskInput = '';
|
||||
if (this.dockInputRef?.nativeElement) {
|
||||
this.dockInputRef.nativeElement.style.height = 'auto';
|
||||
}
|
||||
this.isSubmitting = false;
|
||||
if (res && res.tasks && res.tasks.length > 0) {
|
||||
const firstTask = res.tasks[0];
|
||||
if (firstTask && firstTask.session_id) {
|
||||
const isCurrentlyRunning =
|
||||
this.agentService.agentStatus() === 'running' ||
|
||||
this.agentService.sessions().some((s) => s.status === 'running');
|
||||
if (!isCurrentlyRunning) {
|
||||
this.agentService.selectSession(firstTask.session_id, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.agentService.fetchStatus();
|
||||
},
|
||||
error: (err) => {
|
||||
|
||||
@@ -7,9 +7,38 @@ describe('AgentService live LLM retry timeline', () => {
|
||||
function createServiceWithoutPolling(): AgentService {
|
||||
const service = Object.create(AgentService.prototype) as AgentService;
|
||||
service.sessionLogs = signal<any[]>([]);
|
||||
service.startupProgressBySession = signal({});
|
||||
(service as any).pendingStartupProgress = signal<any[]>([]);
|
||||
return service;
|
||||
}
|
||||
|
||||
it('orders startup milestones and replaces duplicate stages', () => {
|
||||
const service = createServiceWithoutPolling();
|
||||
|
||||
(service as any).appendStartupProgress({
|
||||
session_id: 'session-1',
|
||||
stage: 'device',
|
||||
message: 'Checking device',
|
||||
timestamp: 102
|
||||
}, 'session-1');
|
||||
(service as any).appendStartupProgress({
|
||||
session_id: 'session-1',
|
||||
stage: 'queued',
|
||||
message: 'Task queued',
|
||||
timestamp: 100
|
||||
}, 'session-1');
|
||||
(service as any).appendStartupProgress({
|
||||
session_id: 'session-1',
|
||||
stage: 'device',
|
||||
message: 'Device connected',
|
||||
timestamp: 103
|
||||
}, 'session-1');
|
||||
|
||||
const events = service.startupProgressBySession()['session-1'];
|
||||
expect(events.map((event) => event.stage)).toEqual(['queued', 'device']);
|
||||
expect(events[1].message).toBe('Device connected');
|
||||
});
|
||||
|
||||
it('normalizes a live retry event into the historical trace contract', () => {
|
||||
const service = createServiceWithoutPolling();
|
||||
const event = {
|
||||
@@ -107,6 +136,42 @@ describe('AgentService live LLM retry timeline', () => {
|
||||
|
||||
expect(selectSpy).toHaveBeenCalledWith('new-session', false);
|
||||
});
|
||||
|
||||
it('keeps the paused state when the backend says there is nothing to resume', () => {
|
||||
const service = createServiceWithoutPolling();
|
||||
(service as any).http = { post: () => of({ status: 'not_paused' }) };
|
||||
(service as any).rawSessions = signal<any[]>([{ session_id: 'session-1', status: 'paused' }]);
|
||||
(service as any).pendingQueue = signal<any[]>([]);
|
||||
service.agentStatus = signal('paused');
|
||||
service.runningSessionId = signal<string | null>('session-1');
|
||||
service.isPaused = signal(true);
|
||||
service.pausedError = signal<string | null>('503 unavailable');
|
||||
const statusSpy = spyOn(service, 'fetchStatus');
|
||||
|
||||
service.resumeTask();
|
||||
|
||||
expect(service.agentStatus()).toBe('paused');
|
||||
expect(service.isPaused()).toBeTrue();
|
||||
expect(statusSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('moves the active session to running only after resume succeeds', () => {
|
||||
const service = createServiceWithoutPolling();
|
||||
(service as any).http = { post: () => of({ status: 'resumed' }) };
|
||||
(service as any).rawSessions = signal<any[]>([{ session_id: 'session-1', status: 'paused' }]);
|
||||
(service as any).pendingQueue = signal<any[]>([]);
|
||||
service.agentStatus = signal('paused');
|
||||
service.runningSessionId = signal<string | null>('session-1');
|
||||
service.isPaused = signal(true);
|
||||
service.pausedError = signal<string | null>('503 unavailable');
|
||||
spyOn(service, 'fetchStatus');
|
||||
|
||||
service.resumeTask();
|
||||
|
||||
expect(service.agentStatus()).toBe('running');
|
||||
expect(service.isPaused()).toBeFalse();
|
||||
expect((service as any).rawSessions()[0].status).toBe('running');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentService recording finalization lifecycle', () => {
|
||||
|
||||
@@ -38,6 +38,13 @@ export type RecordingPlaybackStatus =
|
||||
| 'failed'
|
||||
| 'unavailable';
|
||||
|
||||
export interface StartupProgressEvent {
|
||||
session_id?: string;
|
||||
stage: string;
|
||||
message: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface SessionVideoResponse {
|
||||
session_id: string;
|
||||
status?: 'processing' | 'ready' | 'failed' | 'unavailable';
|
||||
@@ -90,7 +97,7 @@ export class AgentService {
|
||||
const isCurrentRunning = (status === 'running' || status === 'paused') && runId === p.session_id;
|
||||
sessionMap.set(p.session_id, {
|
||||
...p,
|
||||
status: isCurrentRunning ? status : 'pending'
|
||||
status: isCurrentRunning ? status : (p.status || 'pending')
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -120,6 +127,17 @@ export class AgentService {
|
||||
public retryMessage = signal<string | null>(null);
|
||||
public activeModel = signal<{ name: string; id: string; provider: string } | null>(null);
|
||||
public userPinnedSessionId = signal<string | null>(null); // Pinned session if user explicitly selected a non-running task
|
||||
public startupProgressBySession = signal<Record<string, StartupProgressEvent[]>>({});
|
||||
private pendingStartupProgress = signal<StartupProgressEvent[]>([]);
|
||||
|
||||
public currentStartupProgress = computed(() => {
|
||||
const sessionId = this.currentSessionId();
|
||||
if (sessionId) {
|
||||
const sessionEvents = this.startupProgressBySession()[sessionId];
|
||||
if (sessionEvents?.length) return sessionEvents;
|
||||
}
|
||||
return this.pendingStartupProgress();
|
||||
});
|
||||
|
||||
// Notes and Tab States
|
||||
public currentNotes = signal<Record<string, string>>({});
|
||||
@@ -210,6 +228,12 @@ export class AgentService {
|
||||
enableOutputter?: boolean
|
||||
): Observable<any> {
|
||||
return new Observable((obs) => {
|
||||
const submittedEvent: StartupProgressEvent = {
|
||||
stage: 'submitting',
|
||||
message: 'Submitting the task',
|
||||
timestamp: Date.now() / 1000
|
||||
};
|
||||
this.pendingStartupProgress.set([submittedEvent]);
|
||||
const payload: any = { goal, profile };
|
||||
if (expectedOutput && expectedOutput.trim()) {
|
||||
payload.expected_output = expectedOutput.trim();
|
||||
@@ -223,6 +247,11 @@ export class AgentService {
|
||||
if (res && res.tasks && res.tasks.length > 0) {
|
||||
const newSessionId = res.tasks[0].session_id;
|
||||
if (newSessionId) {
|
||||
this.appendStartupProgress(
|
||||
{ ...submittedEvent, session_id: newSessionId },
|
||||
newSessionId
|
||||
);
|
||||
this.pendingStartupProgress.set([]);
|
||||
const isCurrentlyRunning =
|
||||
this.agentStatus() === 'running' ||
|
||||
this.sessions().some((s) => s.status === 'running');
|
||||
@@ -236,10 +265,12 @@ export class AgentService {
|
||||
}
|
||||
}
|
||||
}
|
||||
this.pendingStartupProgress.set([]);
|
||||
obs.next(res);
|
||||
obs.complete();
|
||||
},
|
||||
error: (err) => {
|
||||
this.pendingStartupProgress.set([]);
|
||||
obs.error(err);
|
||||
}
|
||||
});
|
||||
@@ -250,6 +281,16 @@ export class AgentService {
|
||||
* Stop the currently running task (and optionally clear queue)
|
||||
*/
|
||||
public stopTask(stopAll: boolean = false): void {
|
||||
const stoppingSessionId = this.runningSessionId()
|
||||
|| this.sessions().find((session) => session.status === 'running' || session.status === 'paused')?.session_id
|
||||
|| null;
|
||||
|
||||
// Keep the task's terminal state stable while the stop request and the
|
||||
// persisted session update cross the network. Clearing the global runner
|
||||
// state first used to make the session merger infer "completed" from the
|
||||
// still-stale DB row, causing a brief completed -> cancelled flicker.
|
||||
this.setSessionStatus(stoppingSessionId, 'cancelled');
|
||||
|
||||
// Apply optimistic updates: only set idle if stopAll is true or pending queue is empty
|
||||
if (stopAll || this.pendingQueue().length === 0) {
|
||||
this.agentStatus.set('idle');
|
||||
@@ -290,10 +331,19 @@ export class AgentService {
|
||||
* Resume paused task
|
||||
*/
|
||||
public resumeTask(): void {
|
||||
this.http.post('/api/resume', {}).subscribe({
|
||||
next: () => {
|
||||
this.http.post<{ status?: string }>('/api/resume', {}).subscribe({
|
||||
next: (response) => {
|
||||
if (response?.status !== 'resumed') {
|
||||
// A stale recovery control must not optimistically change the task
|
||||
// back to running when the backend says there is nothing to resume.
|
||||
this.fetchStatus();
|
||||
return;
|
||||
}
|
||||
const resumedSessionId = this.runningSessionId();
|
||||
this.isPaused.set(false);
|
||||
this.pausedError.set(null);
|
||||
this.agentStatus.set('running');
|
||||
this.setSessionStatus(resumedSessionId, 'running');
|
||||
this.fetchStatus();
|
||||
},
|
||||
error: (err) => {
|
||||
@@ -473,6 +523,7 @@ export class AgentService {
|
||||
'task_paused',
|
||||
'task_resumed',
|
||||
'llm_retrying',
|
||||
'startup_progress',
|
||||
'session_started',
|
||||
'session_ended',
|
||||
'recording_ready',
|
||||
@@ -523,6 +574,7 @@ export class AgentService {
|
||||
this.selectSession(parsedData.session_id, false);
|
||||
}
|
||||
} else if (eventType === 'session_ended') {
|
||||
this.applySessionEndedStatus(evtSessionId, parsedData);
|
||||
this.fetchStatus();
|
||||
this.fetchSessions();
|
||||
this.updateModelForCurrentView();
|
||||
@@ -548,6 +600,7 @@ export class AgentService {
|
||||
}
|
||||
|
||||
if (eventType === 'session_ended') {
|
||||
this.applySessionEndedStatus(sessionId, parsedData);
|
||||
this.agentStatus.set('idle');
|
||||
this.runningSessionId.set(null);
|
||||
this.runningGoal.set(null);
|
||||
@@ -566,6 +619,11 @@ export class AgentService {
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventType === 'startup_progress') {
|
||||
this.appendStartupProgress(parsedData, sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventType === 'llm_retrying') {
|
||||
this.isRetrying.set(true);
|
||||
const attempt = Number(parsedData.attempt || 0);
|
||||
@@ -581,6 +639,9 @@ export class AgentService {
|
||||
if (eventType === 'task_paused') {
|
||||
this.isPaused.set(true);
|
||||
this.isRetrying.set(false);
|
||||
this.agentStatus.set('paused');
|
||||
this.runningSessionId.set(sessionId);
|
||||
this.setSessionStatus(sessionId, 'paused');
|
||||
const pauseError = parsedData.error || 'AI call failed';
|
||||
this.pausedError.set(pauseError);
|
||||
this.appendPausedErrorCard(
|
||||
@@ -597,6 +658,10 @@ export class AgentService {
|
||||
this.isPaused.set(false);
|
||||
this.isRetrying.set(false);
|
||||
this.pausedError.set(null);
|
||||
if (this.runningSessionId() === sessionId) {
|
||||
this.agentStatus.set('running');
|
||||
this.setSessionStatus(sessionId, 'running');
|
||||
}
|
||||
this.activePauseCardKey = null;
|
||||
return;
|
||||
}
|
||||
@@ -888,6 +953,34 @@ export class AgentService {
|
||||
|
||||
private pollCounter: number = 0;
|
||||
|
||||
private setSessionStatus(sessionId: string | null, status: Session['status']): void {
|
||||
if (!sessionId) return;
|
||||
|
||||
this.rawSessions.update((sessions) => sessions.map((session) =>
|
||||
session.session_id === sessionId ? { ...session, status } : session
|
||||
));
|
||||
this.pendingQueue.update((sessions) => sessions.map((session) =>
|
||||
session.session_id === sessionId ? { ...session, status } : session
|
||||
));
|
||||
}
|
||||
|
||||
private applySessionEndedStatus(sessionId: unknown, data: any): void {
|
||||
if (!sessionId) return;
|
||||
|
||||
const reportedStatus = String(data?.status || '').toLowerCase();
|
||||
const status: Session['status'] | null = data?.was_stopped_manually || reportedStatus === 'cancelled'
|
||||
? 'cancelled'
|
||||
: reportedStatus === 'failed'
|
||||
? 'failed'
|
||||
: reportedStatus === 'completed' || reportedStatus === 'success'
|
||||
? 'completed'
|
||||
: null;
|
||||
|
||||
if (status) {
|
||||
this.setSessionStatus(String(sessionId), status);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start periodic status polling from the backend
|
||||
*/
|
||||
@@ -1114,6 +1207,32 @@ export class AgentService {
|
||||
this.requestSessionVideo(targetSessionId, this.videoRequestGeneration);
|
||||
}
|
||||
|
||||
private appendStartupProgress(data: any, sessionId: string): void {
|
||||
if (!data?.stage || !data?.message) return;
|
||||
|
||||
const timestamp = typeof data.timestamp === 'number'
|
||||
? (data.timestamp > 1e11 ? data.timestamp / 1000 : data.timestamp)
|
||||
: Date.now() / 1000;
|
||||
const event: StartupProgressEvent = {
|
||||
session_id: String(data.session_id || sessionId),
|
||||
stage: String(data.stage),
|
||||
message: String(data.message),
|
||||
timestamp
|
||||
};
|
||||
|
||||
this.startupProgressBySession.update((allEvents) => {
|
||||
const current = [...(allEvents[sessionId] || [])];
|
||||
const existingIndex = current.findIndex((item) => item.stage === event.stage);
|
||||
if (existingIndex >= 0) {
|
||||
current[existingIndex] = event;
|
||||
} else {
|
||||
current.push(event);
|
||||
}
|
||||
current.sort((a, b) => a.timestamp - b.timestamp);
|
||||
return { ...allEvents, [sessionId]: current };
|
||||
});
|
||||
}
|
||||
|
||||
private beginRecordingFinalization(sessionId: string): void {
|
||||
if (this.activeVideoSessionId !== sessionId) return;
|
||||
this.cancelVideoRetry();
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
import { getUniqueGenericTools } from './tool-formatter.util';
|
||||
import { cleanErrorMessage, getUniqueGenericTools } from './tool-formatter.util';
|
||||
|
||||
describe('cleanErrorMessage', () => {
|
||||
it('removes repeated LLM wrapper labels while preserving the provider reason', () => {
|
||||
expect(cleanErrorMessage('LLM Error: LLM Request Error: 503 model overloaded'))
|
||||
.toBe('503 model overloaded');
|
||||
});
|
||||
|
||||
it('does not present an empty LLM wrapper label as an error reason', () => {
|
||||
expect(cleanErrorMessage('LLM Error:')).toBe('Unknown error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUniqueGenericTools retry aggregation', () => {
|
||||
it('groups recoverable LLM retries while preserving every attempt', () => {
|
||||
|
||||
@@ -1022,7 +1022,11 @@ export function cleanErrorMessage(rawError: any): string {
|
||||
.replace(/^Pixel-level validation failed:\s*/i, '')
|
||||
.replace(/^Execution error:\s*/i, '')
|
||||
.replace(/^ServerError:\s*/i, '')
|
||||
// LLM wrappers may add this prefix more than once. It is context, not the
|
||||
// actual provider reason, and an empty prefix must not be shown as though
|
||||
// it were a useful error message.
|
||||
.replace(/^(?:LLM\s+(?:Request\s+)?Error\s*:\s*)+/i, '')
|
||||
.trim();
|
||||
|
||||
return fallback || errorStr;
|
||||
return fallback || 'Unknown error';
|
||||
}
|
||||
@@ -21,7 +21,6 @@ import os
|
||||
from pathlib import Path
|
||||
import psutil
|
||||
import re
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
@@ -87,7 +86,7 @@ class ValidatorNode:
|
||||
self.ctx = ctx
|
||||
|
||||
def _kill_mcp_server_instantly(self):
|
||||
"""Instantly terminate MCP ADB server child processes using SIGKILL."""
|
||||
"""Instantly terminate MCP ADB server child processes."""
|
||||
|
||||
try:
|
||||
current_process = psutil.Process()
|
||||
@@ -97,12 +96,50 @@ class ValidatorNode:
|
||||
cmdline = child.cmdline()
|
||||
if any("adb_server.py" in part for part in cmdline):
|
||||
logger.warning(f"Instantly killing MCP Server child process: {child.pid}")
|
||||
os.kill(child.pid, signal.SIGKILL)
|
||||
child.kill()
|
||||
except psutil.NoSuchProcess:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to instantly kill MCP server child processes: {e}")
|
||||
|
||||
async def _close_failed_mcp_session(
|
||||
self,
|
||||
*,
|
||||
session,
|
||||
session_entered: bool,
|
||||
client_ctx,
|
||||
client_entered: bool,
|
||||
) -> bool:
|
||||
"""Exit partially-entered MCP contexts in reverse order.
|
||||
|
||||
The MCP stdio transport owns an AnyIO task group and cancellation
|
||||
scope. Dropping an entered transport without calling ``__aexit__``
|
||||
leaves that scope attached to the validator task and can cancel an
|
||||
unrelated action later in the same node.
|
||||
|
||||
Returns ``True`` when every entered context closed cleanly.
|
||||
"""
|
||||
self.ctx.mcp_session = None
|
||||
self.ctx.mcp_client_ctx = None
|
||||
cleanup_succeeded = True
|
||||
|
||||
for manager, entered, label in (
|
||||
(session, session_entered, "MCP session"),
|
||||
(client_ctx, client_entered, "MCP stdio transport"),
|
||||
):
|
||||
if not entered or manager is None:
|
||||
continue
|
||||
try:
|
||||
await manager.__aexit__(None, None, None)
|
||||
except asyncio.CancelledError:
|
||||
cleanup_succeeded = False
|
||||
logger.warning(f"{label} cleanup was cancelled.")
|
||||
except Exception as cleanup_error:
|
||||
cleanup_succeeded = False
|
||||
logger.warning(f"Failed to close {label}: {cleanup_error}")
|
||||
|
||||
return cleanup_succeeded
|
||||
|
||||
async def _get_mcp_session(self):
|
||||
if getattr(self.ctx, "mcp_session", None) is not None:
|
||||
return self.ctx.mcp_session
|
||||
@@ -111,30 +148,55 @@ class ValidatorNode:
|
||||
venv_python = root_dir / ".venv" / "bin" / "python3"
|
||||
python_exe = str(venv_python) if venv_python.exists() else sys.executable
|
||||
|
||||
server_env = os.environ.copy()
|
||||
# The parent Artemis process already owns the device-awake policy.
|
||||
# Starting it again in this short-lived child can block the MCP
|
||||
# handshake on cross-process ADB/lease cleanup.
|
||||
server_env["ARTEMIS_KEEP_DEVICE_AWAKE"] = "false"
|
||||
server_params = StdioServerParameters(
|
||||
command=python_exe,
|
||||
args=[str(root_dir / "mcp" / "adb_server.py")],
|
||||
env=os.environ.copy(),
|
||||
env=server_env,
|
||||
)
|
||||
|
||||
logger.info("Starting persistent MCP server session...")
|
||||
self.ctx.mcp_client_ctx = stdio_client(server_params)
|
||||
client_ctx = stdio_client(server_params)
|
||||
session = None
|
||||
client_entered = False
|
||||
session_entered = False
|
||||
self.ctx.mcp_client_ctx = client_ctx
|
||||
try:
|
||||
read, write = await self.ctx.mcp_client_ctx.__aenter__()
|
||||
self.ctx.mcp_session = ClientSession(read, write)
|
||||
await self.ctx.mcp_session.__aenter__()
|
||||
read, write = await client_ctx.__aenter__()
|
||||
client_entered = True
|
||||
session = ClientSession(read, write)
|
||||
self.ctx.mcp_session = session
|
||||
await session.__aenter__()
|
||||
session_entered = True
|
||||
# Handshake timeout protection
|
||||
await asyncio.wait_for(self.ctx.mcp_session.initialize(), timeout=15.0)
|
||||
return self.ctx.mcp_session
|
||||
await asyncio.wait_for(session.initialize(), timeout=15.0)
|
||||
return session
|
||||
except asyncio.CancelledError:
|
||||
await self._close_failed_mcp_session(
|
||||
session=session,
|
||||
session_entered=session_entered,
|
||||
client_ctx=client_ctx,
|
||||
client_entered=client_entered,
|
||||
)
|
||||
raise
|
||||
except Exception as e:
|
||||
err_stack = traceback.format_exc()
|
||||
logger.critical(
|
||||
"CRITICAL: Failed to initialize MCP server session:"
|
||||
f" {e}\n{err_stack}. Cleaning up child processes."
|
||||
)
|
||||
self._kill_mcp_server_instantly()
|
||||
self.ctx.mcp_session = None
|
||||
self.ctx.mcp_client_ctx = None
|
||||
cleanup_succeeded = await self._close_failed_mcp_session(
|
||||
session=session,
|
||||
session_entered=session_entered,
|
||||
client_ctx=client_ctx,
|
||||
client_entered=client_entered,
|
||||
)
|
||||
if not cleanup_succeeded:
|
||||
self._kill_mcp_server_instantly()
|
||||
|
||||
logger.warning(
|
||||
"Falling back to robust local in-process execution bypassing MCP protocol."
|
||||
@@ -600,7 +662,7 @@ class ValidatorNode:
|
||||
return await self._execute_validation_loop(state)
|
||||
except asyncio.CancelledError:
|
||||
logger.warning(
|
||||
"Validator task cancelled (Stop requested). Instantly killing MCP server processes."
|
||||
"Validator task cancelled. Instantly killing MCP server processes."
|
||||
)
|
||||
self._kill_mcp_server_instantly()
|
||||
raise
|
||||
|
||||
@@ -21,10 +21,8 @@ Handles Maestro blocker detection and removal before connecting.
|
||||
|
||||
import base64
|
||||
from io import BytesIO
|
||||
import os
|
||||
import re as _re
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
import xml.etree.ElementTree as ET
|
||||
@@ -33,6 +31,7 @@ from PIL import Image
|
||||
from pydantic import BaseModel
|
||||
import uiautomator2 as u2
|
||||
|
||||
from artemis.runtime.awake_service import ensure_device_awake
|
||||
from artemis.utils.logger import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -42,129 +41,6 @@ logger = get_logger(__name__)
|
||||
|
||||
|
||||
MAESTRO_PACKAGE = "dev.mobile.maestro"
|
||||
AWAKE_STRATEGY_STAY_ON = "stay_on"
|
||||
AWAKE_STRATEGY_WAKE_LOCK = "wake_lock"
|
||||
AWAKE_STRATEGY_HEARTBEAT = "heartbeat"
|
||||
AWAKE_HEARTBEAT_INTERVAL_SECONDS = 5.0
|
||||
|
||||
|
||||
def _run_awake_adb_command(
|
||||
device_id: str, args: list[str], description: str
|
||||
) -> subprocess.CompletedProcess[str] | None:
|
||||
"""Run one non-fatal ADB command used by the awake strategy."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["adb", "-s", device_id, *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
detail = (result.stderr or result.stdout).strip()
|
||||
logger.warning(
|
||||
f"Could not {description} on {device_id}: {detail or 'ADB command failed'}"
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not {description} on {device_id}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _keep_device_awake(device_id: str) -> str | None:
|
||||
"""Best-effort preparation of an Android device for UI automation.
|
||||
|
||||
``svc power stayon true`` asks Android to keep the display on while the
|
||||
device has any external power source. It avoids changing the user's
|
||||
normal battery-powered screen timeout. The wake and dismiss commands
|
||||
also recover a device that went to sleep before Artemis connected;
|
||||
``wm dismiss-keyguard`` cannot bypass a secure PIN/password keyguard.
|
||||
|
||||
The primary strategy is verified through PowerManager. If it is not
|
||||
actually active, Artemis tries a shell-owned bright-screen wake lock. If
|
||||
that command is unavailable, the caller starts a periodic KEYCODE_WAKEUP
|
||||
heartbeat. Neither fallback changes the user's screen-timeout setting.
|
||||
|
||||
This must never make device connection fail. Some vendor Android builds
|
||||
restrict one or more shell commands, so failures are logged only.
|
||||
"""
|
||||
enabled = os.environ.get("ARTEMIS_KEEP_DEVICE_AWAKE", "true").strip().lower()
|
||||
if enabled in {"0", "false", "no", "off"}:
|
||||
return None
|
||||
|
||||
commands = (
|
||||
("enable stay-awake while powered", ["shell", "svc", "power", "stayon", "true"]),
|
||||
("wake display", ["shell", "input", "keyevent", "KEYCODE_WAKEUP"]),
|
||||
("dismiss non-secure keyguard", ["shell", "wm", "dismiss-keyguard"]),
|
||||
)
|
||||
for description, args in commands:
|
||||
_run_awake_adb_command(device_id, args, description)
|
||||
|
||||
power_state = _run_awake_adb_command(
|
||||
device_id, ["shell", "dumpsys", "power"], "verify stay-awake state"
|
||||
)
|
||||
if (
|
||||
power_state is not None
|
||||
and power_state.returncode == 0
|
||||
and _re.search(r"\bmStayOn=true\b", power_state.stdout)
|
||||
):
|
||||
logger.info(f"Stay-awake primary strategy verified on {device_id}")
|
||||
return AWAKE_STRATEGY_STAY_ON
|
||||
|
||||
logger.warning(
|
||||
f"Stay-awake primary strategy is not active on {device_id}; "
|
||||
"trying a screen-bright wake lock"
|
||||
)
|
||||
_run_awake_adb_command(
|
||||
device_id,
|
||||
[
|
||||
"shell",
|
||||
"cmd",
|
||||
"power",
|
||||
"set-wakelock",
|
||||
"acquire",
|
||||
"-d",
|
||||
"0",
|
||||
"SCREEN_BRIGHT_WAKE_LOCK",
|
||||
],
|
||||
"acquire a screen-bright wake lock",
|
||||
)
|
||||
wake_locks = _run_awake_adb_command(
|
||||
device_id,
|
||||
["shell", "cmd", "power", "set-wakelock", "list"],
|
||||
"verify the screen-bright wake lock",
|
||||
)
|
||||
if (
|
||||
wake_locks is not None
|
||||
and wake_locks.returncode == 0
|
||||
and _re.search(r"SCREEN_BRIGHT_WAKE_LOCK:.*\bheld=true\b", wake_locks.stdout)
|
||||
):
|
||||
logger.info(f"Screen-bright wake lock verified on {device_id}")
|
||||
return AWAKE_STRATEGY_WAKE_LOCK
|
||||
|
||||
logger.warning(
|
||||
f"Screen-bright wake lock is unavailable on {device_id}; "
|
||||
f"using a {AWAKE_HEARTBEAT_INTERVAL_SECONDS:g}-second wakeup heartbeat"
|
||||
)
|
||||
return AWAKE_STRATEGY_HEARTBEAT
|
||||
|
||||
|
||||
def _release_screen_wake_lock(device_id: str) -> None:
|
||||
"""Release the shell-owned screen wake lock acquired for this session."""
|
||||
_run_awake_adb_command(
|
||||
device_id,
|
||||
[
|
||||
"shell",
|
||||
"cmd",
|
||||
"power",
|
||||
"set-wakelock",
|
||||
"release",
|
||||
"-d",
|
||||
"0",
|
||||
"SCREEN_BRIGHT_WAKE_LOCK",
|
||||
],
|
||||
"release the screen-bright wake lock",
|
||||
)
|
||||
|
||||
|
||||
class _CyFunctionDetectorMeta(type):
|
||||
@@ -383,40 +259,6 @@ class UIAutomatorClient:
|
||||
self._device_id = device_id
|
||||
self._device: Device | None = None
|
||||
self._awake_strategy: str | None = None
|
||||
self._heartbeat_stop_event: threading.Event | None = None
|
||||
self._heartbeat_thread: threading.Thread | None = None
|
||||
|
||||
def _start_awake_heartbeat(self) -> None:
|
||||
"""Start a daemon heartbeat that safely refreshes Android user activity."""
|
||||
if self._heartbeat_thread is not None and self._heartbeat_thread.is_alive():
|
||||
return
|
||||
|
||||
stop_event = threading.Event()
|
||||
self._heartbeat_stop_event = stop_event
|
||||
|
||||
def heartbeat() -> None:
|
||||
while not stop_event.wait(AWAKE_HEARTBEAT_INTERVAL_SECONDS):
|
||||
_run_awake_adb_command(
|
||||
self._device_id,
|
||||
["shell", "input", "keyevent", "KEYCODE_WAKEUP"],
|
||||
"send the stay-awake heartbeat",
|
||||
)
|
||||
|
||||
self._heartbeat_thread = threading.Thread(
|
||||
target=heartbeat,
|
||||
name=f"artemis-awake-{self._device_id}",
|
||||
daemon=True,
|
||||
)
|
||||
self._heartbeat_thread.start()
|
||||
|
||||
def _stop_awake_heartbeat(self) -> None:
|
||||
"""Stop the session heartbeat without delaying shutdown."""
|
||||
if self._heartbeat_stop_event is not None:
|
||||
self._heartbeat_stop_event.set()
|
||||
if self._heartbeat_thread is not None:
|
||||
self._heartbeat_thread.join(timeout=2.0)
|
||||
self._heartbeat_stop_event = None
|
||||
self._heartbeat_thread = None
|
||||
|
||||
def _ensure_connected(self) -> "Device":
|
||||
"""Ensure connection to the device, handling Maestro blocker.
|
||||
@@ -436,18 +278,25 @@ class UIAutomatorClient:
|
||||
# Ensure Maestro is not blocking us
|
||||
_ensure_maestro_not_installed(self._device_id)
|
||||
|
||||
# Configure this automation session before UIAutomator2 reads the screen.
|
||||
# Enroll the device in the containing Artemis service lifetime before
|
||||
# UIAutomator2 reads the screen. Client disconnects do not release it.
|
||||
if self._awake_strategy is None:
|
||||
self._awake_strategy = _keep_device_awake(self._device_id)
|
||||
if self._awake_strategy == AWAKE_STRATEGY_HEARTBEAT:
|
||||
self._start_awake_heartbeat()
|
||||
self._awake_strategy = ensure_device_awake(self._device_id)
|
||||
|
||||
# Connect to device
|
||||
logger.info(f"Connecting UIAutomator2 to device: {self._device_id}")
|
||||
self._device = u2.connect(self._device_id)
|
||||
try:
|
||||
self._device = u2.connect(self._device_id)
|
||||
except Exception:
|
||||
self._awake_strategy = None
|
||||
raise
|
||||
logger.info("UIAutomator2 connected successfully")
|
||||
return self._device
|
||||
|
||||
def connect(self) -> None:
|
||||
"""Connect and activate this session's awake strategy without an action."""
|
||||
self._ensure_connected()
|
||||
|
||||
def press_key(self, key: str):
|
||||
"""Press a key on the device.
|
||||
|
||||
@@ -565,10 +414,7 @@ class UIAutomatorClient:
|
||||
)
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Disconnect from the device."""
|
||||
self._stop_awake_heartbeat()
|
||||
if self._awake_strategy == AWAKE_STRATEGY_WAKE_LOCK:
|
||||
_release_screen_wake_lock(self._device_id)
|
||||
"""Disconnect this client without ending the host's awake lifetime."""
|
||||
self._awake_strategy = None
|
||||
self._device = None
|
||||
logger.info("UIAutomator2 client disconnected")
|
||||
|
||||
@@ -91,6 +91,10 @@ class ReadinessEngine:
|
||||
return None
|
||||
return await probe.probe()
|
||||
|
||||
async def run_device_submission_probe(self) -> ProbeResult:
|
||||
"""Run the bounded device gate used by task submission."""
|
||||
return await self._adb_probe.probe_submission_readiness()
|
||||
|
||||
async def run_all(self, categories: list[ProbeCategory] | None = None) -> SystemReadinessReport:
|
||||
"""Concurrently run all registered diagnostic probes and compile report."""
|
||||
toolchain.clear_cache()
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import asyncio
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from typing import Any
|
||||
@@ -120,6 +121,242 @@ class AdbDeviceProbe(BaseProbe):
|
||||
logger.debug(f"Failed to read adb version: {e}")
|
||||
return "Installed"
|
||||
|
||||
@staticmethod
|
||||
def _parse_device_lock_state(policy_output: str, trust_output: str) -> bool | None:
|
||||
"""Parse Android Keyguard state from dumpsys output.
|
||||
|
||||
``KeyguardServiceDelegate.showing`` also catches swipe-only lock screens,
|
||||
while Trust's current-user ``deviceLocked`` covers secure locks and
|
||||
screen-off transitions. Any positive signal wins so an occluded lock
|
||||
screen cannot accidentally be treated as ready.
|
||||
"""
|
||||
policy_showing: bool | None = None
|
||||
policy_lines = policy_output.splitlines()
|
||||
for index, raw_line in enumerate(policy_lines):
|
||||
if raw_line.strip() != "KeyguardServiceDelegate":
|
||||
continue
|
||||
for delegate_line in policy_lines[index + 1 : index + 16]:
|
||||
match = re.fullmatch(
|
||||
r"showing\s*=\s*(true|false|1|0)",
|
||||
delegate_line.strip(),
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
if match:
|
||||
policy_showing = match.group(1).lower() in {"true", "1"}
|
||||
break
|
||||
break
|
||||
|
||||
# Older Android releases expose differently named policy fields.
|
||||
legacy_matches = re.findall(
|
||||
r"\b(?:mShowingLockscreen|mKeyguardShowing|keyguardShowing|"
|
||||
r"isKeyguardLocked|showingAndNotOccluded)\s*=\s*(true|false|1|0)",
|
||||
policy_output,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
legacy_states = [value.lower() in {"true", "1"} for value in legacy_matches]
|
||||
|
||||
trust_match = re.search(
|
||||
r"^.*\(current\).*?\bdeviceLocked\s*=\s*(true|false|1|0)\b",
|
||||
trust_output,
|
||||
flags=re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
if trust_match is None:
|
||||
trust_match = re.search(
|
||||
r"\bdeviceLocked\s*=\s*(true|false|1|0)\b",
|
||||
trust_output,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
trust_locked = (
|
||||
trust_match.group(1).lower() in {"true", "1"} if trust_match else None
|
||||
)
|
||||
|
||||
states = [state for state in [policy_showing, trust_locked, *legacy_states] if state is not None]
|
||||
if any(states):
|
||||
return True
|
||||
if states:
|
||||
return False
|
||||
return None
|
||||
|
||||
async def _get_device_lock_state(
|
||||
self,
|
||||
adb_path: str,
|
||||
serial: str,
|
||||
timeout_seconds: float = 2.0,
|
||||
) -> bool | None:
|
||||
"""Query Keyguard state without changing or waking the target device."""
|
||||
|
||||
async def run_dumpsys(*service_args: str) -> str:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
adb_path,
|
||||
"-s",
|
||||
serial,
|
||||
"shell",
|
||||
"dumpsys",
|
||||
*service_args,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
stdout, _ = await asyncio.wait_for(
|
||||
proc.communicate(), timeout=timeout_seconds
|
||||
)
|
||||
except TimeoutError:
|
||||
proc.kill()
|
||||
await proc.communicate()
|
||||
return ""
|
||||
if proc.returncode != 0:
|
||||
return ""
|
||||
return stdout.decode(errors="replace")
|
||||
|
||||
try:
|
||||
policy_output, trust_output = await asyncio.gather(
|
||||
run_dumpsys("window", "policy"),
|
||||
run_dumpsys("trust"),
|
||||
)
|
||||
return self._parse_device_lock_state(policy_output, trust_output)
|
||||
except Exception as exc:
|
||||
logger.debug(f"Failed to query lock state for {serial}: {exc}")
|
||||
return None
|
||||
|
||||
async def _get_device_states(
|
||||
self,
|
||||
adb_path: str,
|
||||
timeout_seconds: float = 1.0,
|
||||
) -> list[tuple[str, str]] | None:
|
||||
"""Return only ADB serial/state pairs for the submission fast path.
|
||||
|
||||
``None`` means the command could not be completed within the bounded
|
||||
submission window. An empty list means ADB responded with no devices.
|
||||
"""
|
||||
proc = None
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
adb_path,
|
||||
"devices",
|
||||
"-l",
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
stdout, _ = await asyncio.wait_for(
|
||||
proc.communicate(), timeout=timeout_seconds
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
return None
|
||||
|
||||
states: list[tuple[str, str]] = []
|
||||
lines = stdout.decode(errors="replace").splitlines()
|
||||
for raw_line in lines[1:]:
|
||||
parts = raw_line.strip().split()
|
||||
if len(parts) >= 2:
|
||||
states.append((parts[0], parts[1]))
|
||||
return states
|
||||
except TimeoutError:
|
||||
if proc is not None:
|
||||
proc.kill()
|
||||
await proc.communicate()
|
||||
return None
|
||||
except Exception as exc:
|
||||
logger.debug(f"Failed submission-time ADB device check: {exc}")
|
||||
return None
|
||||
|
||||
async def probe_submission_readiness(self) -> ProbeResult:
|
||||
"""Run the minimal fail-safe device check required before enqueueing.
|
||||
|
||||
The full diagnostics probe enriches device metadata, scans packages,
|
||||
and discovers emulators. None of that is needed to reject a locked
|
||||
device at submission time, so this path only checks ADB connectivity
|
||||
and Keyguard state with strict time bounds.
|
||||
"""
|
||||
adb_path = toolchain.resolve("adb")
|
||||
if not adb_path:
|
||||
return ProbeResult(
|
||||
id=self.probe_id,
|
||||
category=self.category,
|
||||
title="Device / Emulator Connected",
|
||||
status=ProbeStatus.FAIL,
|
||||
is_blocker=self.is_blocker,
|
||||
summary="ADB Not Found",
|
||||
description="Android Debug Bridge (adb) is not installed or not in PATH.",
|
||||
metadata={"installed": False, "submission_probe": True},
|
||||
)
|
||||
|
||||
device_states = await self._get_device_states(adb_path)
|
||||
if device_states is None:
|
||||
return ProbeResult(
|
||||
id=self.probe_id,
|
||||
category=self.category,
|
||||
title="Device / Emulator Connected",
|
||||
status=ProbeStatus.WARN,
|
||||
is_blocker=self.is_blocker,
|
||||
summary="Lock State Unknown",
|
||||
description=(
|
||||
"ADB did not respond quickly enough to verify the Android lock-screen state."
|
||||
),
|
||||
metadata={"installed": True, "submission_probe": True},
|
||||
)
|
||||
|
||||
ready_serials = [serial for serial, state in device_states if state == "device"]
|
||||
if not ready_serials:
|
||||
states = {state for _, state in device_states}
|
||||
summary = "Device Unauthorized" if "unauthorized" in states else "No Device Found"
|
||||
return ProbeResult(
|
||||
id=self.probe_id,
|
||||
category=self.category,
|
||||
title="Device / Emulator Connected",
|
||||
status=ProbeStatus.WARN,
|
||||
is_blocker=self.is_blocker,
|
||||
summary=summary,
|
||||
description="No authorized Android device is currently ready.",
|
||||
metadata={
|
||||
"installed": True,
|
||||
"submission_probe": True,
|
||||
"devices": [
|
||||
{"serial": serial, "state": state}
|
||||
for serial, state in device_states
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
serial = (
|
||||
self._target_serial
|
||||
if self._target_serial in ready_serials
|
||||
else ready_serials[0]
|
||||
)
|
||||
is_locked = await self._get_device_lock_state(
|
||||
adb_path,
|
||||
serial,
|
||||
timeout_seconds=1.0,
|
||||
)
|
||||
summary = (
|
||||
"Device Locked"
|
||||
if is_locked is True
|
||||
else "Connected"
|
||||
if is_locked is False
|
||||
else "Lock State Unknown"
|
||||
)
|
||||
status = ProbeStatus.PASS if is_locked is False else ProbeStatus.WARN
|
||||
description = (
|
||||
f"ADB connected to {serial}. Ready for task submission."
|
||||
if is_locked is False
|
||||
else f"Android device {serial} is locked. Unlock it before running a task."
|
||||
if is_locked is True
|
||||
else f"Android lock-screen state could not be verified for {serial}."
|
||||
)
|
||||
return ProbeResult(
|
||||
id=self.probe_id,
|
||||
category=self.category,
|
||||
title="Device / Emulator Connected",
|
||||
status=status,
|
||||
is_blocker=self.is_blocker,
|
||||
summary=summary,
|
||||
description=description,
|
||||
metadata={
|
||||
"installed": True,
|
||||
"submission_probe": True,
|
||||
"active_device": {"serial": serial, "is_locked": is_locked},
|
||||
},
|
||||
)
|
||||
|
||||
async def _parse_adb_devices(self, adb_path: str) -> list[DeviceInfo]:
|
||||
"""Execute `adb devices -l` and extract structured device metadata."""
|
||||
devices: list[DeviceInfo] = []
|
||||
@@ -197,8 +434,12 @@ class AdbDeviceProbe(BaseProbe):
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
lock_state_task = asyncio.create_task(
|
||||
self._get_device_lock_state(adb_path, dev.serial)
|
||||
)
|
||||
out_prop, _ = await p_prop.communicate()
|
||||
out_size, _ = await p_size.communicate()
|
||||
dev.is_locked = await lock_state_task
|
||||
|
||||
if out_prop:
|
||||
dev.android_version = out_prop.decode(errors="replace").strip()
|
||||
@@ -430,6 +671,37 @@ class AdbDeviceProbe(BaseProbe):
|
||||
|
||||
metadata["active_device"] = active_dev.model_dump()
|
||||
|
||||
if active_dev.is_locked is not False:
|
||||
is_locked = active_dev.is_locked is True
|
||||
summary = "Device Locked" if is_locked else "Lock State Unknown"
|
||||
description = (
|
||||
f"ADB connected to {display_name}, but the Android lock screen is active. "
|
||||
"Unlock the device and enter the home screen before running a task."
|
||||
if is_locked
|
||||
else f"ADB connected to {display_name}, but Android lock-screen state could not "
|
||||
"be verified. Keep the device unlocked on the home screen and check again."
|
||||
)
|
||||
return ProbeResult(
|
||||
id=self.probe_id,
|
||||
category=self.category,
|
||||
title="Device / Emulator Connected",
|
||||
status=ProbeStatus.WARN,
|
||||
is_blocker=self.is_blocker,
|
||||
summary=summary,
|
||||
description=description,
|
||||
metadata=metadata,
|
||||
actions=[
|
||||
ProbeAction(
|
||||
action_type="hint",
|
||||
label="Unlock Device" if is_locked else "Verify Device Screen",
|
||||
payload=(
|
||||
"Unlock the Android device and leave it on the home screen. "
|
||||
"Readiness is checked automatically every few seconds."
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
return ProbeResult(
|
||||
id=self.probe_id,
|
||||
category=self.category,
|
||||
|
||||
@@ -64,6 +64,10 @@ class DeviceInfo(BaseModel):
|
||||
screen_resolution: str | None = Field(
|
||||
default=None, description="Screen width x height (e.g. 1080x2400)"
|
||||
)
|
||||
is_locked: bool | None = Field(
|
||||
default=None,
|
||||
description="Whether Android Keyguard currently blocks access; None when undetermined",
|
||||
)
|
||||
is_emulator: bool = Field(default=False, description="Whether the device is an emulator")
|
||||
installed_packages: list[str] = Field(
|
||||
default_factory=list, description="List of recognized installed package names"
|
||||
|
||||
@@ -242,6 +242,15 @@ class DataEngine:
|
||||
pid=os.getpid(),
|
||||
)
|
||||
self.storage.create_session(session)
|
||||
try:
|
||||
from artemis.runtime import DeviceExecutionLock
|
||||
|
||||
DeviceExecutionLock.annotate_active_owner(
|
||||
session_id=str(session_id),
|
||||
ingress=os.getenv("ARTEMIS_TASK_INGRESS") or "sdk",
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug(f"Could not annotate active device owner: {exc}")
|
||||
self.current_step_number = 0
|
||||
logger.info(f"Session started: {session_id}")
|
||||
self._publish("session_started", session.model_dump())
|
||||
|
||||
@@ -26,6 +26,7 @@ from mcp_server.base import mcp as agent_mcp
|
||||
import mcp_server.tools # noqa: F401
|
||||
from mcp_server.utils import env_utils
|
||||
from artemis.mcp.adb_server import mcp as adb_mcp
|
||||
from artemis.runtime import shutdown_awake_service, start_awake_service
|
||||
from artemis.utils.logger import get_logger
|
||||
from rich.console import Console
|
||||
from rich.syntax import Syntax
|
||||
@@ -723,27 +724,33 @@ def mcp_command(
|
||||
console.print(syntax)
|
||||
raise typer.Exit(0)
|
||||
|
||||
st = server_type.lower()
|
||||
if st in ("agent", "mobile", "artemis", "default"):
|
||||
logger.info(f"Starting Artemis Mobile Agent MCP Server over {transport}...")
|
||||
if transport.lower() == "sse":
|
||||
agent_mcp.run(transport="sse", host=host, port=port)
|
||||
else:
|
||||
agent_mcp.run(transport="stdio")
|
||||
elif st == "adb":
|
||||
logger.info(f"Starting Artemis ADB MCP Server over {transport}...")
|
||||
if transport.lower() == "sse":
|
||||
adb_mcp.run(transport="sse", host=host, port=port)
|
||||
else:
|
||||
adb_mcp.run(transport="stdio")
|
||||
elif st == "xml":
|
||||
from artemis.mcp.xml_search_server import mcp as xml_mcp
|
||||
start_awake_service()
|
||||
try:
|
||||
st = server_type.lower()
|
||||
if st in ("agent", "mobile", "artemis", "default"):
|
||||
logger.info(f"Starting Artemis Mobile Agent MCP Server over {transport}...")
|
||||
if transport.lower() == "sse":
|
||||
agent_mcp.run(transport="sse", host=host, port=port)
|
||||
else:
|
||||
agent_mcp.run(transport="stdio")
|
||||
elif st == "adb":
|
||||
logger.info(f"Starting Artemis ADB MCP Server over {transport}...")
|
||||
if transport.lower() == "sse":
|
||||
adb_mcp.run(transport="sse", host=host, port=port)
|
||||
else:
|
||||
adb_mcp.run(transport="stdio")
|
||||
elif st == "xml":
|
||||
from artemis.mcp.xml_search_server import mcp as xml_mcp
|
||||
|
||||
logger.info(f"Starting Artemis XML Fuzzy Search MCP Server over {transport}...")
|
||||
if transport.lower() == "sse":
|
||||
xml_mcp.run(transport="sse", host=host, port=port)
|
||||
logger.info(f"Starting Artemis XML Fuzzy Search MCP Server over {transport}...")
|
||||
if transport.lower() == "sse":
|
||||
xml_mcp.run(transport="sse", host=host, port=port)
|
||||
else:
|
||||
xml_mcp.run(transport="stdio")
|
||||
else:
|
||||
xml_mcp.run(transport="stdio")
|
||||
else:
|
||||
logger.error(f"Unsupported MCP server type: {server_type}. Use 'agent', 'adb', or 'xml'.")
|
||||
raise typer.Exit(1)
|
||||
logger.error(
|
||||
f"Unsupported MCP server type: {server_type}. Use 'agent', 'adb', or 'xml'."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
finally:
|
||||
shutdown_awake_service()
|
||||
@@ -23,6 +23,7 @@ from typing import Annotated
|
||||
from adbutils import AdbClient
|
||||
from langchain_core.callbacks.base import Callbacks
|
||||
from artemis.config import initialize_llm_config, settings
|
||||
from artemis.utils.startup_progress import publish_startup_progress
|
||||
from artemis import Agent, Builders
|
||||
from artemis.sdk.types.task import AgentProfile
|
||||
from artemis.utils.cli_helpers import display_device_status
|
||||
@@ -73,6 +74,7 @@ async def execute_task(
|
||||
explorer_flash_mode: Override Explorer version mode for Flash execution profile.
|
||||
explorer_pro_mode: Override Explorer version mode for Pro execution profile.
|
||||
"""
|
||||
publish_startup_progress("configuration", "Loading the run configuration")
|
||||
llm_config = initialize_llm_config()
|
||||
agent_profile = AgentProfile(name="default", llm_config=llm_config)
|
||||
config = Builders.AgentConfig.with_default_profile(profile=agent_profile)
|
||||
@@ -117,11 +119,13 @@ async def execute_task(
|
||||
|
||||
agent: Agent | None = None
|
||||
try:
|
||||
publish_startup_progress("device_check", "Checking the Android device")
|
||||
agent = Agent(config=config.build())
|
||||
await agent.init(
|
||||
retry_count=int(os.getenv("ARTEMIS_HEALTH_RETRIES", 5)),
|
||||
retry_wait_seconds=int(os.getenv("ARTEMIS_HEALTH_DELAY", 2)),
|
||||
)
|
||||
publish_startup_progress("device_ready", "Android device connected")
|
||||
|
||||
task = agent.new_task(goal)
|
||||
if locked_app_package:
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
"""UI Launch and Web Console CLI Command (artemis ui)."""
|
||||
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
@@ -27,6 +29,13 @@ from rich.panel import Panel
|
||||
import typer
|
||||
|
||||
|
||||
def _resolve_npm_executable(platform_name: str | None = None) -> str | None:
|
||||
"""Return a directly executable npm path for the current platform."""
|
||||
platform_name = platform_name or sys.platform
|
||||
candidates = ("npm.cmd", "npm.exe", "npm") if platform_name == "win32" else ("npm",)
|
||||
return next((path for candidate in candidates if (path := shutil.which(candidate))), None)
|
||||
|
||||
|
||||
def _showcase_build_required(showcase_dir: Path) -> bool:
|
||||
"""Return whether the compiled Angular app is missing or older than its inputs."""
|
||||
base_dist = showcase_dir / "dist"
|
||||
@@ -123,16 +132,16 @@ def ui_command(
|
||||
|
||||
showcase_dir = ROOT_DIR / "apps" / "showcase_ui"
|
||||
if _showcase_build_required(showcase_dir):
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
if shutil.which("npm"):
|
||||
npm_executable = _resolve_npm_executable()
|
||||
if npm_executable:
|
||||
console.print(
|
||||
" [yellow]🎨 Showcase UI sources changed. Compiling Angular Showcase UI...[/yellow]"
|
||||
)
|
||||
try:
|
||||
subprocess.run(["npm", "install", "--silent"], cwd=showcase_dir, check=True)
|
||||
subprocess.run(["npm", "run", "build"], cwd=showcase_dir, check=True)
|
||||
subprocess.run(
|
||||
[npm_executable, "install", "--silent"], cwd=showcase_dir, check=True
|
||||
)
|
||||
subprocess.run([npm_executable, "run", "build"], cwd=showcase_dir, check=True)
|
||||
console.print(" [green]✓ Showcase UI built successfully.[/green]\n")
|
||||
except Exception as e:
|
||||
console.print(f" [red]⚠ Failed to auto-build Showcase UI: {e}[/red]\n")
|
||||
|
||||
@@ -470,4 +470,10 @@ async def get_ui_hierarchy(ctx: Context) -> str:
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run(transport="stdio")
|
||||
from artemis.runtime import shutdown_awake_service, start_awake_service
|
||||
|
||||
start_awake_service()
|
||||
try:
|
||||
mcp.run(transport="stdio")
|
||||
finally:
|
||||
shutdown_awake_service()
|
||||
@@ -284,4 +284,10 @@ def search_by_coordinates(image_hash: str, x: int, y: int) -> str:
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run()
|
||||
from artemis.runtime import shutdown_awake_service, start_awake_service
|
||||
|
||||
start_awake_service()
|
||||
try:
|
||||
mcp.run()
|
||||
finally:
|
||||
shutdown_awake_service()
|
||||
@@ -15,11 +15,21 @@
|
||||
"""Artemis cross-platform runtime and process supervisor subsystem."""
|
||||
|
||||
from artemis.runtime.device_lock import DeviceBusyError, DeviceExecutionLock
|
||||
from artemis.runtime.awake_service import (
|
||||
ensure_device_awake,
|
||||
screen_awake_service,
|
||||
shutdown_awake_service,
|
||||
start_awake_service,
|
||||
)
|
||||
from artemis.runtime.supervisor import ProcessSupervisor, process_supervisor
|
||||
|
||||
__all__ = [
|
||||
"DeviceBusyError",
|
||||
"DeviceExecutionLock",
|
||||
"ensure_device_awake",
|
||||
"ProcessSupervisor",
|
||||
"process_supervisor",
|
||||
"screen_awake_service",
|
||||
"shutdown_awake_service",
|
||||
"start_awake_service",
|
||||
]
|
||||
@@ -0,0 +1,288 @@
|
||||
# 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.
|
||||
|
||||
"""Cross-process ownership for Android's shell screen-bright wake lock."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from datetime import UTC, datetime
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from artemis.config.paths import get_temp_dir
|
||||
from artemis.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_WAKE_LOCK_TYPE = "SCREEN_BRIGHT_WAKE_LOCK"
|
||||
_WAKE_LOCK_RE = re.compile(
|
||||
rf"{_WAKE_LOCK_TYPE}:.*\bheld=(true|false)\b.*\brefCount=(\d+)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
class ScreenAwakeLease:
|
||||
"""Share exactly one Android shell wake-lock reference across clients.
|
||||
|
||||
``cmd power set-wakelock`` exposes one ref-counted shell wake lock for a
|
||||
display/type pair. Calling it independently from every process leaks
|
||||
references whenever a process crashes. PID-aware lease files make the
|
||||
Android reference a process-shared resource instead: the first live client
|
||||
normalizes stale references and acquires one, later clients only register,
|
||||
and the last client drains the shared reference on disconnect.
|
||||
"""
|
||||
|
||||
_MUTEX_TIMEOUT_SECONDS = 10.0
|
||||
_MUTEX_POLL_SECONDS = 0.05
|
||||
_MALFORMED_GRACE_SECONDS = 5.0
|
||||
_MAX_REFERENCE_DRAIN = 256
|
||||
|
||||
def __init__(self, device_id: str):
|
||||
self.device_id = device_id
|
||||
self.token = uuid.uuid4().hex
|
||||
device_hash = hashlib.sha256(device_id.encode("utf-8")).hexdigest()[:16]
|
||||
root = get_temp_dir("awake-leases")
|
||||
self.lease_dir = root / f"{device_hash}.leases"
|
||||
self.mutex_path = root / f"{device_hash}.mutex"
|
||||
self.lease_path = self.lease_dir / f"{self.token}.lease"
|
||||
self._acquired = False
|
||||
|
||||
@staticmethod
|
||||
def _process_created_at(pid: int | None = None) -> float:
|
||||
try:
|
||||
import psutil
|
||||
|
||||
return float(psutil.Process(pid or os.getpid()).create_time())
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
@staticmethod
|
||||
def _owner_is_alive(payload: dict) -> bool:
|
||||
try:
|
||||
import psutil
|
||||
|
||||
pid = int(payload["pid"])
|
||||
process = psutil.Process(pid)
|
||||
if not process.is_running():
|
||||
return False
|
||||
created_at = float(payload.get("process_created_at", 0.0))
|
||||
return created_at <= 0 or abs(process.create_time() - created_at) < 1.0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _read_payload(path: Path) -> dict | None:
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
return value if isinstance(value, dict) else None
|
||||
except (OSError, ValueError, TypeError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _path_is_stale(cls, path: Path) -> bool:
|
||||
payload = cls._read_payload(path)
|
||||
if payload is not None:
|
||||
return not cls._owner_is_alive(payload)
|
||||
try:
|
||||
return time.time() - path.stat().st_mtime >= cls._MALFORMED_GRACE_SECONDS
|
||||
except OSError:
|
||||
return True
|
||||
|
||||
def _payload(self) -> dict:
|
||||
return {
|
||||
"pid": os.getpid(),
|
||||
"process_created_at": self._process_created_at(),
|
||||
"token": self.token,
|
||||
"device_id": self.device_id,
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
|
||||
@contextmanager
|
||||
def _mutex(self):
|
||||
self.mutex_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
started_at = time.monotonic()
|
||||
acquired = False
|
||||
while not acquired:
|
||||
try:
|
||||
fd = os.open(self.mutex_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||
except FileExistsError:
|
||||
if self._path_is_stale(self.mutex_path):
|
||||
try:
|
||||
self.mutex_path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
continue
|
||||
if time.monotonic() - started_at >= self._MUTEX_TIMEOUT_SECONDS:
|
||||
raise TimeoutError("Timed out acquiring the Artemis awake-lease mutex.")
|
||||
time.sleep(self._MUTEX_POLL_SECONDS)
|
||||
else:
|
||||
try:
|
||||
os.write(fd, json.dumps(self._payload()).encode("utf-8"))
|
||||
finally:
|
||||
os.close(fd)
|
||||
acquired = True
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
payload = self._read_payload(self.mutex_path)
|
||||
if payload is not None and payload.get("token") == self.token:
|
||||
try:
|
||||
self.mutex_path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _run(self, args: list[str], description: str) -> subprocess.CompletedProcess[str] | None:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["adb", "-s", self.device_id, *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
detail = (result.stderr or result.stdout).strip()
|
||||
logger.warning(
|
||||
f"Could not {description} on {self.device_id}: "
|
||||
f"{detail or 'ADB command failed'}"
|
||||
)
|
||||
return result
|
||||
except Exception as exc:
|
||||
logger.warning(f"Could not {description} on {self.device_id}: {exc}")
|
||||
return None
|
||||
|
||||
def _wake_lock_state(self) -> tuple[bool, int] | None:
|
||||
result = self._run(
|
||||
["shell", "cmd", "power", "set-wakelock", "list"],
|
||||
"inspect the shared screen-bright wake lock",
|
||||
)
|
||||
if result is None or result.returncode != 0:
|
||||
return None
|
||||
match = _WAKE_LOCK_RE.search(result.stdout)
|
||||
if match is None:
|
||||
return (False, 0)
|
||||
return (match.group(1).lower() == "true", int(match.group(2)))
|
||||
|
||||
def _set_wake_lock(self, action: str) -> bool:
|
||||
result = self._run(
|
||||
[
|
||||
"shell",
|
||||
"cmd",
|
||||
"power",
|
||||
"set-wakelock",
|
||||
action,
|
||||
"-d",
|
||||
"0",
|
||||
_WAKE_LOCK_TYPE,
|
||||
],
|
||||
f"{action} the shared screen-bright wake lock",
|
||||
)
|
||||
return result is not None and result.returncode == 0
|
||||
|
||||
def _drain_references(self) -> bool:
|
||||
"""Release legacy or crashed-process references until none remain."""
|
||||
for _ in range(self._MAX_REFERENCE_DRAIN):
|
||||
state = self._wake_lock_state()
|
||||
if state is None:
|
||||
return False
|
||||
held, count = state
|
||||
if not held or count <= 0:
|
||||
return True
|
||||
if not self._set_wake_lock("release"):
|
||||
return False
|
||||
logger.warning(
|
||||
f"Refused to drain more than {self._MAX_REFERENCE_DRAIN} shared wake-lock "
|
||||
f"references on {self.device_id}"
|
||||
)
|
||||
return False
|
||||
|
||||
def _live_leases(self) -> list[Path]:
|
||||
self.lease_dir.mkdir(parents=True, exist_ok=True)
|
||||
live: list[Path] = []
|
||||
for path in self.lease_dir.glob("*.lease"):
|
||||
if self._path_is_stale(path):
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
else:
|
||||
live.append(path)
|
||||
return live
|
||||
|
||||
def acquire(self) -> bool:
|
||||
if self._acquired:
|
||||
return True
|
||||
try:
|
||||
with self._mutex():
|
||||
live = self._live_leases()
|
||||
state = self._wake_lock_state()
|
||||
if state is None:
|
||||
return False
|
||||
|
||||
if not live:
|
||||
# No registered owner means every existing reference is
|
||||
# legacy or belongs to a crashed process.
|
||||
if not self._drain_references():
|
||||
return False
|
||||
state = (False, 0)
|
||||
|
||||
held, count = state
|
||||
if not held or count <= 0:
|
||||
if not self._set_wake_lock("acquire"):
|
||||
return False
|
||||
verified = self._wake_lock_state()
|
||||
if verified is None or not verified[0] or verified[1] <= 0:
|
||||
return False
|
||||
|
||||
self.lease_path.write_text(
|
||||
json.dumps(self._payload(), ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
self._acquired = True
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning(f"Could not acquire shared awake lease on {self.device_id}: {exc}")
|
||||
return False
|
||||
|
||||
def release(self) -> None:
|
||||
if not self._acquired:
|
||||
return
|
||||
try:
|
||||
with self._mutex():
|
||||
self.lease_path.unlink(missing_ok=True)
|
||||
if not self._live_leases():
|
||||
self._drain_references()
|
||||
except Exception as exc:
|
||||
logger.warning(f"Could not release shared awake lease on {self.device_id}: {exc}")
|
||||
finally:
|
||||
self._acquired = False
|
||||
|
||||
def cleanup_unowned_references(self) -> bool:
|
||||
"""Drain legacy shell wake locks only when no live old host owns them."""
|
||||
try:
|
||||
with self._mutex():
|
||||
if self._live_leases():
|
||||
return False
|
||||
return self._drain_references()
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
f"Could not clean unowned screen wake locks on {self.device_id}: {exc}"
|
||||
)
|
||||
return False
|
||||
@@ -0,0 +1,300 @@
|
||||
# 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.
|
||||
|
||||
"""USB-scoped screen-awake policy shared by every local Artemis entrypoint."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import threading
|
||||
from typing import Iterable
|
||||
|
||||
from adbutils import AdbClient
|
||||
|
||||
from artemis.config import settings
|
||||
from artemis.runtime.awake_lease import ScreenAwakeLease
|
||||
from artemis.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
AWAKE_STRATEGY_USB = "usb_stay_on"
|
||||
AWAKE_STRATEGY_HEARTBEAT = "host_heartbeat"
|
||||
AWAKE_HEARTBEAT_INTERVAL_SECONDS = 5.0
|
||||
DEVICE_DISCOVERY_INTERVAL_SECONDS = 2.0
|
||||
|
||||
|
||||
def _awake_enabled() -> bool:
|
||||
enabled = os.environ.get("ARTEMIS_KEEP_DEVICE_AWAKE", "true").strip().lower()
|
||||
return enabled not in {"0", "false", "no", "off"}
|
||||
|
||||
|
||||
def _run_awake_adb_command(
|
||||
device_id: str, args: list[str], description: str
|
||||
) -> subprocess.CompletedProcess[str] | None:
|
||||
"""Run one non-fatal ADB command used by the USB awake policy."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["adb", "-s", device_id, *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
detail = (result.stderr or result.stdout).strip()
|
||||
logger.warning(
|
||||
f"Could not {description} on {device_id}: {detail or 'ADB command failed'}"
|
||||
)
|
||||
return result
|
||||
except Exception as exc:
|
||||
logger.warning(f"Could not {description} on {device_id}: {exc}")
|
||||
return None
|
||||
|
||||
|
||||
def _configure_usb_stay_awake(device_id: str) -> str | None:
|
||||
"""Enable USB stay-awake, falling back to an effective host heartbeat.
|
||||
|
||||
The policy is intentionally scoped to USB power rather than an Artemis
|
||||
process or task. Android applies the normal screen timeout as soon as USB
|
||||
power is removed, even though no ADB command can be sent after unplugging.
|
||||
AC, wireless, and dock charging retain the user's normal behavior.
|
||||
"""
|
||||
if not _awake_enabled() or os.environ.get("ARTEMIS_CLOUD_MODE") == "1":
|
||||
return None
|
||||
|
||||
# Migration cleanup: previous Artemis versions used a persistent shell
|
||||
# wake lock. Drain it only after every live legacy owner has exited.
|
||||
ScreenAwakeLease(device_id).cleanup_unowned_references()
|
||||
|
||||
commands = (
|
||||
(
|
||||
"enable USB-scoped stay-awake policy",
|
||||
["shell", "svc", "power", "stayon", "usb"],
|
||||
),
|
||||
("wake display", ["shell", "input", "keyevent", "KEYCODE_WAKEUP"]),
|
||||
("dismiss non-secure keyguard", ["shell", "wm", "dismiss-keyguard"]),
|
||||
)
|
||||
for description, args in commands:
|
||||
_run_awake_adb_command(device_id, args, description)
|
||||
|
||||
configured = _run_awake_adb_command(
|
||||
device_id,
|
||||
["shell", "settings", "get", "global", "stay_on_while_plugged_in"],
|
||||
"verify USB-scoped stay-awake policy",
|
||||
)
|
||||
power_state = _run_awake_adb_command(
|
||||
device_id,
|
||||
["shell", "dumpsys", "power"],
|
||||
"verify active USB stay-awake state",
|
||||
)
|
||||
try:
|
||||
value = int(configured.stdout.strip()) if configured is not None else -1
|
||||
except (TypeError, ValueError):
|
||||
value = -1
|
||||
stay_on_active = (
|
||||
configured is not None
|
||||
and configured.returncode == 0
|
||||
and value == 2
|
||||
and power_state is not None
|
||||
and power_state.returncode == 0
|
||||
and re.search(r"\bmStayOn=true\b", power_state.stdout) is not None
|
||||
)
|
||||
if stay_on_active:
|
||||
logger.info(f"USB-scoped stay-awake policy verified active on {device_id}")
|
||||
return AWAKE_STRATEGY_USB
|
||||
|
||||
logger.warning(
|
||||
f"USB stay-awake is not active on {device_id} (setting={value}); "
|
||||
f"using a {AWAKE_HEARTBEAT_INTERVAL_SECONDS:g}-second host heartbeat"
|
||||
)
|
||||
primed = _run_awake_adb_command(
|
||||
device_id,
|
||||
["shell", "input", "keyevent", "KEYCODE_UNKNOWN"],
|
||||
"prime the host stay-awake heartbeat",
|
||||
)
|
||||
if primed is None or primed.returncode != 0:
|
||||
return None
|
||||
return AWAKE_STRATEGY_HEARTBEAT
|
||||
|
||||
|
||||
def _discover_connected_device_ids() -> list[str]:
|
||||
"""Return the selected or first connected local Android device."""
|
||||
if not _awake_enabled() or os.environ.get("ARTEMIS_CLOUD_MODE") == "1":
|
||||
return []
|
||||
|
||||
target = os.environ.get("ARTEMIS_DEVICE_ID") or os.environ.get("ADB_DEVICE_SERIAL")
|
||||
host = os.environ.get("ADB_HOST") or settings.ADB_HOST or "127.0.0.1"
|
||||
port_text = os.environ.get("ADB_PORT")
|
||||
try:
|
||||
port = int(port_text) if port_text else int(settings.ADB_PORT or 5037)
|
||||
except (TypeError, ValueError):
|
||||
port = 5037
|
||||
|
||||
try:
|
||||
devices = AdbClient(host=host, port=port).device_list()
|
||||
serials = [device.serial for device in devices if device.serial]
|
||||
except Exception as exc:
|
||||
logger.debug(f"Could not discover a device for the USB awake policy: {exc}")
|
||||
return []
|
||||
|
||||
if target:
|
||||
return [target] if target in serials else []
|
||||
# Artemis has a single-device execution policy. Do not modify unrelated
|
||||
# devices that happen to share the ADB server.
|
||||
return serials[:1]
|
||||
|
||||
|
||||
class ScreenAwakeService:
|
||||
"""Apply USB stay-awake or a connection-scoped host heartbeat."""
|
||||
|
||||
def __init__(self):
|
||||
self._lock = threading.RLock()
|
||||
self._strategies: dict[str, str] = {}
|
||||
self._heartbeat_stops: dict[str, threading.Event] = {}
|
||||
self._heartbeat_threads: dict[str, threading.Thread] = {}
|
||||
self._monitor_stop: threading.Event | None = None
|
||||
self._monitor_thread: threading.Thread | None = None
|
||||
|
||||
def start(self, device_ids: Iterable[str] | None = None) -> dict[str, str | None]:
|
||||
selected = list(device_ids) if device_ids is not None else _discover_connected_device_ids()
|
||||
results = {
|
||||
device_id: self.ensure_device(device_id)
|
||||
for device_id in dict.fromkeys(selected)
|
||||
if device_id
|
||||
}
|
||||
if _awake_enabled() and os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
|
||||
self._start_device_monitor()
|
||||
return results
|
||||
|
||||
def ensure_device(self, device_id: str) -> str | None:
|
||||
"""Configure one device once per process; Android owns unplug behavior."""
|
||||
if not device_id or not _awake_enabled() or os.environ.get("ARTEMIS_CLOUD_MODE") == "1":
|
||||
return None
|
||||
with self._lock:
|
||||
existing = self._strategies.get(device_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
strategy = _configure_usb_stay_awake(device_id)
|
||||
if strategy is not None:
|
||||
self._strategies[device_id] = strategy
|
||||
if strategy == AWAKE_STRATEGY_HEARTBEAT:
|
||||
self._start_heartbeat(device_id)
|
||||
return strategy
|
||||
|
||||
def _start_heartbeat(self, device_id: str) -> None:
|
||||
if device_id in self._heartbeat_threads:
|
||||
return
|
||||
stop_event = threading.Event()
|
||||
|
||||
def heartbeat() -> None:
|
||||
while not stop_event.wait(AWAKE_HEARTBEAT_INTERVAL_SECONDS):
|
||||
_run_awake_adb_command(
|
||||
device_id,
|
||||
["shell", "input", "keyevent", "KEYCODE_UNKNOWN"],
|
||||
"send the host stay-awake heartbeat",
|
||||
)
|
||||
|
||||
thread = threading.Thread(
|
||||
target=heartbeat,
|
||||
name=f"artemis-awake-heartbeat-{device_id}",
|
||||
daemon=True,
|
||||
)
|
||||
self._heartbeat_stops[device_id] = stop_event
|
||||
self._heartbeat_threads[device_id] = thread
|
||||
thread.start()
|
||||
|
||||
def _start_device_monitor(self) -> None:
|
||||
with self._lock:
|
||||
if self._monitor_thread is not None and self._monitor_thread.is_alive():
|
||||
return
|
||||
stop_event = threading.Event()
|
||||
|
||||
def monitor() -> None:
|
||||
while not stop_event.wait(DEVICE_DISCOVERY_INTERVAL_SECONDS):
|
||||
connected = set(_discover_connected_device_ids())
|
||||
if stop_event.is_set():
|
||||
break
|
||||
self._reconcile_connected_devices(connected)
|
||||
|
||||
thread = threading.Thread(
|
||||
target=monitor,
|
||||
name="artemis-awake-device-monitor",
|
||||
daemon=True,
|
||||
)
|
||||
self._monitor_stop = stop_event
|
||||
self._monitor_thread = thread
|
||||
thread.start()
|
||||
|
||||
def _reconcile_connected_devices(self, connected: set[str]) -> None:
|
||||
"""Stop unplugged heartbeats and enroll newly attached devices."""
|
||||
with self._lock:
|
||||
missing = set(self._strategies) - connected
|
||||
for device_id in missing:
|
||||
self._stop_device(device_id)
|
||||
for device_id in connected:
|
||||
self.ensure_device(device_id)
|
||||
|
||||
def _stop_device(self, device_id: str) -> None:
|
||||
with self._lock:
|
||||
stop_event = self._heartbeat_stops.pop(device_id, None)
|
||||
thread = self._heartbeat_threads.pop(device_id, None)
|
||||
self._strategies.pop(device_id, None)
|
||||
if stop_event is not None:
|
||||
stop_event.set()
|
||||
if thread is not None and thread is not threading.current_thread():
|
||||
thread.join(timeout=2.0)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Stop host work; unplug behavior remains owned by Android's USB policy."""
|
||||
with self._lock:
|
||||
monitor_stop = self._monitor_stop
|
||||
monitor_thread = self._monitor_thread
|
||||
heartbeat_stops = list(self._heartbeat_stops.values())
|
||||
heartbeat_threads = list(self._heartbeat_threads.values())
|
||||
if monitor_stop is not None:
|
||||
monitor_stop.set()
|
||||
for stop_event in heartbeat_stops:
|
||||
stop_event.set()
|
||||
self._monitor_stop = None
|
||||
self._monitor_thread = None
|
||||
self._heartbeat_stops.clear()
|
||||
self._heartbeat_threads.clear()
|
||||
self._strategies.clear()
|
||||
|
||||
if monitor_thread is not None:
|
||||
monitor_thread.join(timeout=2.0)
|
||||
for thread in heartbeat_threads:
|
||||
thread.join(timeout=2.0)
|
||||
|
||||
@property
|
||||
def device_ids(self) -> tuple[str, ...]:
|
||||
with self._lock:
|
||||
return tuple(self._strategies)
|
||||
|
||||
|
||||
screen_awake_service = ScreenAwakeService()
|
||||
|
||||
|
||||
def start_awake_service(device_ids: Iterable[str] | None = None) -> dict[str, str | None]:
|
||||
return screen_awake_service.start(device_ids)
|
||||
|
||||
|
||||
def ensure_device_awake(device_id: str) -> str | None:
|
||||
return screen_awake_service.ensure_device(device_id)
|
||||
|
||||
|
||||
def shutdown_awake_service() -> None:
|
||||
screen_awake_service.shutdown()
|
||||
+361
-34
@@ -12,13 +12,12 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Cross-process exclusive ownership for a local mobile device."""
|
||||
"""Cross-process FIFO execution queue for the local Artemis device."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
@@ -40,6 +39,8 @@ class DeviceLockOwner:
|
||||
device_id: str
|
||||
description: str
|
||||
acquired_at: str
|
||||
session_id: str | None = None
|
||||
ingress: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: dict) -> DeviceLockOwner:
|
||||
@@ -50,27 +51,57 @@ class DeviceLockOwner:
|
||||
device_id=str(value["device_id"]),
|
||||
description=str(value.get("description", "unknown task")),
|
||||
acquired_at=str(value.get("acquired_at", "unknown time")),
|
||||
session_id=(
|
||||
str(value["session_id"])
|
||||
if value.get("session_id") is not None
|
||||
else None
|
||||
),
|
||||
ingress=(str(value["ingress"]) if value.get("ingress") is not None else None),
|
||||
)
|
||||
|
||||
|
||||
class DeviceExecutionLock:
|
||||
"""A PID-aware lock shared by UI, CLI, and MCP task processes.
|
||||
"""A PID-aware FIFO lease shared by UI, CLI, SDK, and MCP tasks.
|
||||
|
||||
Atomic file creation provides the inter-process exclusion. PID creation
|
||||
time prevents a stale lock from blocking a device after a process exits or
|
||||
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.
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
_MALFORMED_LOCK_GRACE_SECONDS = 5.0
|
||||
_DEFAULT_POLL_INTERVAL_SECONDS = 0.1
|
||||
QUEUE_TICKET_ENV = "ARTEMIS_DEVICE_QUEUE_TICKET"
|
||||
|
||||
def __init__(self, device_id: str, description: str = "Artemis task"):
|
||||
def __init__(
|
||||
self,
|
||||
device_id: str,
|
||||
description: str = "Artemis task",
|
||||
queue_ticket: str | None = None,
|
||||
):
|
||||
self.device_id = device_id
|
||||
self.description = description
|
||||
self.token = uuid.uuid4().hex
|
||||
digest = hashlib.sha256(device_id.encode("utf-8", errors="replace")).hexdigest()[:20]
|
||||
self.path = get_temp_dir("device-locks") / f"device-{digest}.lock"
|
||||
lock_dir = get_temp_dir("device-locks")
|
||||
self.path = lock_dir / "artemis-global-device.lock"
|
||||
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(
|
||||
"ARTEMIS_CLOUD_SESSION_ID"
|
||||
)
|
||||
self.ingress = os.getenv("ARTEMIS_TASK_INGRESS") or "sdk"
|
||||
self._queue_path: Path | None = None
|
||||
self._acquired = False
|
||||
|
||||
@classmethod
|
||||
def _global_lock_path(cls) -> Path:
|
||||
return get_temp_dir("device-locks") / "artemis-global-device.lock"
|
||||
|
||||
@staticmethod
|
||||
def _current_process_created_at() -> float:
|
||||
try:
|
||||
@@ -120,41 +151,243 @@ class DeviceExecutionLock:
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
def acquire(self) -> None:
|
||||
owner_payload = {
|
||||
@classmethod
|
||||
def _owner_payload(
|
||||
cls,
|
||||
token: str,
|
||||
device_id: str,
|
||||
description: str,
|
||||
*,
|
||||
session_id: str | None = None,
|
||||
ingress: str | None = None,
|
||||
) -> dict:
|
||||
payload = {
|
||||
"pid": os.getpid(),
|
||||
"process_created_at": self._current_process_created_at(),
|
||||
"token": self.token,
|
||||
"device_id": self.device_id,
|
||||
"description": self.description,
|
||||
"process_created_at": cls._current_process_created_at(),
|
||||
"token": token,
|
||||
"device_id": device_id,
|
||||
"description": description,
|
||||
"acquired_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
if session_id:
|
||||
payload["session_id"] = str(session_id)
|
||||
if ingress:
|
||||
payload["ingress"] = str(ingress)
|
||||
return payload
|
||||
|
||||
for _ in range(4):
|
||||
@classmethod
|
||||
def reserve(
|
||||
cls,
|
||||
description: str = "Artemis task",
|
||||
device_id: str = "pending",
|
||||
*,
|
||||
session_id: str | None = None,
|
||||
ingress: str | None = None,
|
||||
) -> str:
|
||||
"""Reserve a global FIFO position 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)
|
||||
payload = cls._owner_payload(
|
||||
token,
|
||||
device_id,
|
||||
description,
|
||||
session_id=session_id,
|
||||
ingress=ingress,
|
||||
)
|
||||
for _ in range(8):
|
||||
path = queue_dir / f"{time.time_ns():020d}-{token}.wait"
|
||||
try:
|
||||
fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||
fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||
except FileExistsError:
|
||||
if self._remove_stale_lock():
|
||||
continue
|
||||
owner = self._read_owner(self.path)
|
||||
owner_text = (
|
||||
f"PID {owner.pid} ({owner.description}, since {owner.acquired_at})"
|
||||
if owner is not None
|
||||
else "another process"
|
||||
)
|
||||
raise DeviceBusyError(
|
||||
f"Device '{self.device_id}' is already controlled by {owner_text}. "
|
||||
"Wait for that task to finish or stop it before starting another task."
|
||||
)
|
||||
continue
|
||||
else:
|
||||
try:
|
||||
os.write(fd, json.dumps(owner_payload, ensure_ascii=False).encode("utf-8"))
|
||||
os.write(fd, json.dumps(payload, ensure_ascii=False).encode("utf-8"))
|
||||
finally:
|
||||
os.close(fd)
|
||||
self._acquired = True
|
||||
return
|
||||
return token
|
||||
raise DeviceBusyError("Could not reserve a position in the Artemis device queue.")
|
||||
|
||||
raise DeviceBusyError(f"Could not acquire the execution lock for '{self.device_id}'.")
|
||||
@classmethod
|
||||
def cancel_reservation(cls, token: str | None) -> bool:
|
||||
"""Remove a pending queue ticket. Active execution leases are untouched."""
|
||||
if not token:
|
||||
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
|
||||
return removed
|
||||
|
||||
@classmethod
|
||||
def transfer_reservation(
|
||||
cls,
|
||||
token: str,
|
||||
pid: int,
|
||||
*,
|
||||
description: str = "Artemis task",
|
||||
device_id: str = "pending",
|
||||
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.
|
||||
"""
|
||||
queue_dir = get_temp_dir("device-locks") / "artemis-global-device.queue"
|
||||
matches = list(queue_dir.glob(f"*-{token}.wait"))
|
||||
path = min(matches, default=None)
|
||||
if path is None:
|
||||
return False
|
||||
try:
|
||||
import psutil
|
||||
|
||||
process_created_at = float(psutil.Process(pid).create_time())
|
||||
except Exception:
|
||||
process_created_at = 0.0
|
||||
payload = {
|
||||
"pid": pid,
|
||||
"process_created_at": process_created_at,
|
||||
"token": token,
|
||||
"device_id": device_id,
|
||||
"description": description,
|
||||
"acquired_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
if session_id:
|
||||
payload["session_id"] = str(session_id)
|
||||
if ingress:
|
||||
payload["ingress"] = str(ingress)
|
||||
try:
|
||||
path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _find_reserved_ticket(self) -> Path | None:
|
||||
if not self.queue_ticket:
|
||||
return None
|
||||
matches = list(self.queue_dir.glob(f"*-{self.queue_ticket}.wait"))
|
||||
return min(matches, default=None)
|
||||
|
||||
def _claim_or_create_queue_ticket(self) -> Path:
|
||||
self.queue_dir.mkdir(parents=True, exist_ok=True)
|
||||
if self.queue_ticket:
|
||||
path = self._find_reserved_ticket()
|
||||
if path is None:
|
||||
raise DeviceBusyError(
|
||||
"The Artemis queue reservation was cancelled or expired before execution."
|
||||
)
|
||||
payload = self._owner_payload(
|
||||
self.queue_ticket,
|
||||
self.device_id,
|
||||
self.description,
|
||||
session_id=self.session_id,
|
||||
ingress=self.ingress,
|
||||
)
|
||||
path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
||||
return path
|
||||
|
||||
self.queue_ticket = self.reserve(self.description, self.device_id)
|
||||
path = self._find_reserved_ticket()
|
||||
if path is None:
|
||||
raise DeviceBusyError("The Artemis device queue ticket could not be created.")
|
||||
return path
|
||||
|
||||
def _remove_stale_queue_entries(self) -> int:
|
||||
removed = 0
|
||||
if not self.queue_dir.exists():
|
||||
return removed
|
||||
for path in self.queue_dir.glob("*.wait"):
|
||||
if self._queue_path is not None and path == self._queue_path:
|
||||
continue
|
||||
owner = self._read_owner(path)
|
||||
if owner is not None and self._owner_is_alive(owner):
|
||||
continue
|
||||
if owner is None:
|
||||
try:
|
||||
if time.time() - path.stat().st_mtime < self._MALFORMED_LOCK_GRACE_SECONDS:
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
removed += 1
|
||||
except OSError:
|
||||
pass
|
||||
return removed
|
||||
|
||||
def _try_acquire_owner_lock(self) -> bool:
|
||||
owner_payload = self._owner_payload(
|
||||
self.token,
|
||||
self.device_id,
|
||||
self.description,
|
||||
session_id=self.session_id,
|
||||
ingress=self.ingress,
|
||||
)
|
||||
try:
|
||||
fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||
except FileExistsError:
|
||||
self._remove_stale_lock()
|
||||
return False
|
||||
try:
|
||||
os.write(fd, json.dumps(owner_payload, ensure_ascii=False).encode("utf-8"))
|
||||
finally:
|
||||
os.close(fd)
|
||||
self._acquired = True
|
||||
return True
|
||||
|
||||
def acquire(
|
||||
self,
|
||||
*,
|
||||
blocking: bool = True,
|
||||
timeout: float | None = None,
|
||||
poll_interval: float = _DEFAULT_POLL_INTERVAL_SECONDS,
|
||||
cancel_event=None,
|
||||
) -> None:
|
||||
"""Wait in FIFO order and acquire exclusive access to the Artemis device."""
|
||||
self._queue_path = self._claim_or_create_queue_ticket()
|
||||
started_at = time.monotonic()
|
||||
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)
|
||||
self._queue_path = None
|
||||
return
|
||||
|
||||
if not blocking:
|
||||
owner = self._read_owner(self.path)
|
||||
owner_text = (
|
||||
f"PID {owner.pid} ({owner.description}, since {owner.acquired_at})"
|
||||
if owner is not None
|
||||
else "an earlier queued task"
|
||||
)
|
||||
raise DeviceBusyError(
|
||||
f"Device '{self.device_id}' is already controlled by {owner_text}."
|
||||
)
|
||||
if timeout is not None and time.monotonic() - started_at >= timeout:
|
||||
raise DeviceBusyError(
|
||||
f"Timed out waiting for the Artemis device queue after {timeout:.1f}s."
|
||||
)
|
||||
time.sleep(max(0.01, poll_interval))
|
||||
except BaseException:
|
||||
if self._queue_path is not None:
|
||||
self._queue_path.unlink(missing_ok=True)
|
||||
self._queue_path = None
|
||||
raise
|
||||
|
||||
def release(self) -> None:
|
||||
if not self._acquired:
|
||||
@@ -166,12 +399,106 @@ class DeviceExecutionLock:
|
||||
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()
|
||||
owner = None
|
||||
for attempt in range(3):
|
||||
owner = cls._read_owner(path)
|
||||
if owner is not None or not path.exists():
|
||||
break
|
||||
if attempt < 2:
|
||||
time.sleep(0.01)
|
||||
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)
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
if cls._owner_is_alive(owner):
|
||||
return owner
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
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()
|
||||
|
||||
@classmethod
|
||||
def is_active_owner(cls, expected: DeviceLockOwner) -> bool:
|
||||
"""Revalidate a previously read owner before performing a destructive action."""
|
||||
current = cls._read_owner(cls._global_lock_path())
|
||||
return bool(
|
||||
current
|
||||
and current.token == expected.token
|
||||
and current.pid == expected.pid
|
||||
and abs(current.process_created_at - expected.process_created_at) < 1.0
|
||||
and cls._owner_is_alive(current)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def annotate_active_owner(
|
||||
cls,
|
||||
*,
|
||||
session_id: str,
|
||||
ingress: str | None = None,
|
||||
) -> bool:
|
||||
"""Attach session metadata after tracing starts inside the lease owner."""
|
||||
path = cls._global_lock_path()
|
||||
owner = cls._read_owner(path)
|
||||
if owner is None or owner.pid != os.getpid() or not cls._owner_is_alive(owner):
|
||||
return False
|
||||
payload = {
|
||||
"pid": owner.pid,
|
||||
"process_created_at": owner.process_created_at,
|
||||
"token": owner.token,
|
||||
"device_id": owner.device_id,
|
||||
"description": owner.description,
|
||||
"acquired_at": owner.acquired_at,
|
||||
"session_id": str(session_id),
|
||||
"ingress": ingress or owner.ingress or "sdk",
|
||||
}
|
||||
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)
|
||||
except OSError:
|
||||
return False
|
||||
finally:
|
||||
try:
|
||||
temp_path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def cleanup_stale_locks(cls) -> int:
|
||||
"""Remove lock files whose owning process is no longer alive."""
|
||||
"""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("device-*.lock"):
|
||||
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:
|
||||
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
|
||||
|
||||
@@ -65,6 +65,32 @@ class ProcessSupervisor:
|
||||
"""Recursively terminate a process tree."""
|
||||
return platform.process.terminate_process_tree(pid, timeout_seconds=timeout_seconds)
|
||||
|
||||
@staticmethod
|
||||
def terminate_tree_verified(
|
||||
pid: int,
|
||||
process_created_at: float,
|
||||
timeout_seconds: float = 3.0,
|
||||
) -> bool:
|
||||
"""Terminate a process tree only if PID and creation time still match.
|
||||
|
||||
Cross-entry task control discovers the worker through a shared lease.
|
||||
Verifying creation time prevents a stale lease from terminating an
|
||||
unrelated process after the operating system reuses its PID.
|
||||
"""
|
||||
try:
|
||||
import psutil
|
||||
except Exception:
|
||||
return False
|
||||
try:
|
||||
process = psutil.Process(pid)
|
||||
if process_created_at > 0 and abs(process.create_time() - process_created_at) >= 1.0:
|
||||
return False
|
||||
except psutil.NoSuchProcess:
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
return ProcessSupervisor.terminate_tree(pid, timeout_seconds=timeout_seconds)
|
||||
|
||||
@staticmethod
|
||||
async def stop_process(
|
||||
proc: asyncio.subprocess.Process | None,
|
||||
|
||||
+91
-16
@@ -14,6 +14,7 @@
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
|
||||
try:
|
||||
from datetime import UTC, datetime
|
||||
@@ -26,6 +27,7 @@ from platform import system
|
||||
import shutil
|
||||
from shutil import which
|
||||
import sys
|
||||
import threading
|
||||
from types import NoneType
|
||||
from typing import TypeVar, overload
|
||||
import uuid
|
||||
@@ -82,6 +84,7 @@ from artemis.utils.media import (
|
||||
remove_images_from_trace_folder,
|
||||
remove_steps_json_from_trace_folder,
|
||||
)
|
||||
from artemis.utils.startup_progress import publish_startup_progress
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -184,6 +187,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")
|
||||
logger.info("Starting background pre-warming of Gemini API connection pools...")
|
||||
try:
|
||||
key = api_key
|
||||
@@ -192,6 +196,9 @@ 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"
|
||||
)
|
||||
return
|
||||
|
||||
# 1. Pre-warm Native SDK client
|
||||
@@ -207,8 +214,12 @@ class Agent:
|
||||
return_exceptions=True,
|
||||
)
|
||||
logger.success("Gemini API connection pools successfully pre-warmed.")
|
||||
publish_startup_progress("model_ready", "Model connection is ready")
|
||||
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"
|
||||
)
|
||||
|
||||
async def install_apk(self, apk_path: str | Path) -> None:
|
||||
"""Install an APK on the connected device.
|
||||
@@ -447,12 +458,6 @@ class Agent:
|
||||
agent_config=self._config,
|
||||
)
|
||||
|
||||
self._prepare_tracing(task=task, context=context)
|
||||
await self._prepare_app_installation(task=task)
|
||||
await self._prepare_device_environment(context=context)
|
||||
await self._prepare_app_lock(task=task, context=context)
|
||||
self._prepare_output_files(task=task)
|
||||
|
||||
output_config = None
|
||||
if request.output_description or request.output_format:
|
||||
output_config = OutputConfig(
|
||||
@@ -476,7 +481,46 @@ class Agent:
|
||||
)
|
||||
try:
|
||||
if os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
|
||||
await asyncio.to_thread(device_lock.acquire)
|
||||
queue_cancel_event = threading.Event()
|
||||
acquire_task = asyncio.create_task(
|
||||
asyncio.to_thread(
|
||||
device_lock.acquire,
|
||||
cancel_event=queue_cancel_event,
|
||||
)
|
||||
)
|
||||
try:
|
||||
await asyncio.shield(acquire_task)
|
||||
except asyncio.CancelledError:
|
||||
queue_cancel_event.set()
|
||||
try:
|
||||
await asyncio.shield(acquire_task)
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
# All device mutation and UI initialization happens only after
|
||||
# this task reaches the head of the one global FIFO queue.
|
||||
# Session creation must be inside the same boundary as well:
|
||||
# DataEngine uses a shared database, so a queued task must not
|
||||
# publish a new active session while the current task is still
|
||||
# finishing.
|
||||
if os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
|
||||
if self._ui_adb_client is not None:
|
||||
publish_startup_progress(
|
||||
"uiautomator", "Connecting to UI Automator"
|
||||
)
|
||||
await asyncio.to_thread(self._ui_adb_client.connect)
|
||||
publish_startup_progress(
|
||||
"uiautomator_ready", "UI Automator is ready"
|
||||
)
|
||||
await self._ensure_device_unlocked()
|
||||
publish_startup_progress(
|
||||
"environment", "Preparing the device environment"
|
||||
)
|
||||
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)
|
||||
async with context:
|
||||
recording_started = False
|
||||
if self._config.video_recording_tools_enabled:
|
||||
@@ -503,6 +547,9 @@ class Agent:
|
||||
except Exception as e:
|
||||
logger.error(f"[{task_name}] Failed to start screen recording: {e}")
|
||||
|
||||
publish_startup_progress(
|
||||
"environment_ready", "Device environment is ready"
|
||||
)
|
||||
try:
|
||||
if request.profile and request.profile.lower() == "flash":
|
||||
logger.info(f"[{task_name}] Invoking FlashRunner reactive loop...")
|
||||
@@ -645,7 +692,11 @@ class Agent:
|
||||
await self._finalize_tracing_safely(task=task, context=context)
|
||||
finally:
|
||||
if os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
|
||||
await asyncio.to_thread(device_lock.release)
|
||||
try:
|
||||
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)
|
||||
|
||||
async with self._task_lock:
|
||||
if self._current_task and not self._current_task.done():
|
||||
@@ -707,9 +758,35 @@ class Agent:
|
||||
if not self._initialized and not force:
|
||||
return
|
||||
|
||||
if self._ui_adb_client is not None:
|
||||
await asyncio.to_thread(self._ui_adb_client.disconnect)
|
||||
self._initialized = False
|
||||
logger.info("✅ Artemis agent stopped.")
|
||||
|
||||
async def _ensure_device_unlocked(self) -> None:
|
||||
"""Reject secure keyguard instead of allowing an agent to guess credentials."""
|
||||
if self._adb_client is None:
|
||||
raise AgentError("ADB client is not initialized.")
|
||||
|
||||
device = self._adb_client.device(serial=self._device_context.device_id)
|
||||
try:
|
||||
trust_state = str(await asyncio.to_thread(device.shell, "dumpsys trust"))
|
||||
except Exception as exc:
|
||||
logger.warning(f"Could not inspect Android keyguard state: {exc}")
|
||||
return
|
||||
|
||||
# The first deviceLocked value belongs to the current Android user;
|
||||
# later entries may describe a separately locked work profile.
|
||||
match = re.search(r"\bdeviceLocked=(?:true|1|false|0)\b", trust_state, re.IGNORECASE)
|
||||
if match is None:
|
||||
return
|
||||
value = match.group(0).split("=", 1)[1].lower()
|
||||
if value in {"true", "1"}:
|
||||
raise AgentError(
|
||||
"Android secure keyguard is locked. Unlock the device manually before "
|
||||
"running Artemis; automation will not guess a PIN, password, or pattern."
|
||||
)
|
||||
|
||||
async def _prepare_app_installation(self, task: Task) -> str | None:
|
||||
"""Install app if app_path is specified in the task request.
|
||||
|
||||
@@ -1008,19 +1085,17 @@ class Agent:
|
||||
device_height=height,
|
||||
)
|
||||
|
||||
# Get real device dimensions from the device
|
||||
if self._ui_adb_client:
|
||||
# Query dimensions without starting UIAutomator or acquiring an awake
|
||||
# strategy. Those belong inside the global execution queue lease.
|
||||
if self._adb_client:
|
||||
try:
|
||||
# Use UIAutomator to get actual screen dimensions
|
||||
screen_data = self._ui_adb_client.get_screen_data()
|
||||
device_width = screen_data.width
|
||||
device_height = screen_data.height
|
||||
device_width, device_height = self._adb_client.device(device_id).window_size()
|
||||
logger.info(f"Retrieved Android screen dimensions: {device_width}x{device_height}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get Android screen dimensions: {e}, using defaults")
|
||||
logger.warning(f"Failed to get Android window size: {e}, using defaults")
|
||||
device_width, device_height = 1080, 2340
|
||||
else:
|
||||
logger.warning("UIAutomator client not available, using default dimensions")
|
||||
logger.warning("ADB client not available, using default dimensions")
|
||||
device_width, device_height = 1080, 2340
|
||||
|
||||
from artemis.platform import platform as pal_platform
|
||||
|
||||
+15
-5
@@ -576,13 +576,14 @@ async def invoke_llm_with_timeout_message[T](
|
||||
"""Send an LLM call and display a countdown / timeout message if delayed."""
|
||||
llm_task = asyncio.create_task(llm_call)
|
||||
waiter_task = asyncio.create_task(asyncio.sleep(timeout_seconds))
|
||||
try:
|
||||
done, _ = await asyncio.wait(
|
||||
{llm_task, waiter_task}, return_when=asyncio.FIRST_COMPLETED
|
||||
)
|
||||
|
||||
done, _ = await asyncio.wait({llm_task, waiter_task}, return_when=asyncio.FIRST_COMPLETED)
|
||||
if llm_task in done:
|
||||
return llm_task.result()
|
||||
|
||||
if llm_task in done:
|
||||
waiter_task.cancel()
|
||||
return llm_task.result()
|
||||
else:
|
||||
user_messages_logger.info("Waiting for LLM call response...")
|
||||
start_time = asyncio.get_event_loop().time()
|
||||
|
||||
@@ -602,6 +603,15 @@ async def invoke_llm_with_timeout_message[T](
|
||||
if elapsed > max(0, hard_timeout - timeout_seconds):
|
||||
user_messages_logger.error(f"LLM call timed out after {hard_timeout} seconds.")
|
||||
raise TimeoutError(f"LLM call timed out after {hard_timeout} seconds.")
|
||||
except BaseException:
|
||||
if not llm_task.done():
|
||||
llm_task.cancel()
|
||||
await asyncio.gather(llm_task, return_exceptions=True)
|
||||
raise
|
||||
finally:
|
||||
if not waiter_task.done():
|
||||
waiter_task.cancel()
|
||||
await asyncio.gather(waiter_task, return_exceptions=True)
|
||||
|
||||
|
||||
# Backward compatible factory functions delegating to ModelFactory
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# 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
|
||||
|
||||
"""Best-effort startup progress events for UI clients.
|
||||
|
||||
Startup happens before the normal DataEngine event bridge exists. This small
|
||||
sender lets the worker report those otherwise invisible phases over the same
|
||||
local IPC protocol without making task startup depend on the UI server.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from artemis.config import read_ipc_port
|
||||
|
||||
|
||||
def publish_startup_progress(stage: str, message: str, **details: Any) -> None:
|
||||
"""Publish one startup milestone, ignoring unavailable UI/IPC endpoints."""
|
||||
session_id = 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:
|
||||
return
|
||||
|
||||
event_data = {
|
||||
"session_id": session_id,
|
||||
"stage": stage,
|
||||
"message": message,
|
||||
"timestamp": time.time(),
|
||||
**details,
|
||||
}
|
||||
encoded = (
|
||||
json.dumps(
|
||||
{"event_type": "startup_progress", "data": event_data},
|
||||
default=str,
|
||||
)
|
||||
+ "\n"
|
||||
).encode("utf-8")
|
||||
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", ipc_port), timeout=0.25) as sock:
|
||||
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||
sock.sendall(encoded)
|
||||
except OSError:
|
||||
# Progress reporting is observability only and must never delay or fail
|
||||
# the automation task itself.
|
||||
return
|
||||
@@ -56,14 +56,19 @@ from mcp_server.base import mcp
|
||||
|
||||
# 2. Import tools to register them
|
||||
import mcp_server.tools # noqa: F401
|
||||
from artemis.runtime import shutdown_awake_service, start_awake_service
|
||||
|
||||
|
||||
def main(transport: str = "stdio", host: str = "127.0.0.1", port: int = 8001):
|
||||
"""Main entrypoint to run the Artemis Mobile Agent MCP server."""
|
||||
if transport.lower() == "sse":
|
||||
mcp.run(transport="sse", host=host, port=port)
|
||||
else:
|
||||
mcp.run(transport="stdio")
|
||||
start_awake_service()
|
||||
try:
|
||||
if transport.lower() == "sse":
|
||||
mcp.run(transport="sse", host=host, port=port)
|
||||
else:
|
||||
mcp.run(transport="stdio")
|
||||
finally:
|
||||
shutdown_awake_service()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -257,6 +257,7 @@ def mobile_manage_task(
|
||||
}
|
||||
|
||||
try:
|
||||
DeviceExecutionLock.cancel_reservation(status_data.get("queue_ticket"))
|
||||
if sys.platform == "win32":
|
||||
if not process_supervisor.terminate_tree(pid):
|
||||
import psutil
|
||||
|
||||
@@ -21,6 +21,7 @@ import uuid
|
||||
|
||||
from mcp_server.base import mcp
|
||||
from mcp_server.utils import env_utils, trace_store
|
||||
from artemis.runtime import DeviceExecutionLock
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@@ -139,6 +140,11 @@ def mobile_run_task(
|
||||
# 3. 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",
|
||||
)
|
||||
|
||||
# 4. Spawn the background task runner as an independent subprocess
|
||||
try:
|
||||
@@ -164,6 +170,8 @@ def mobile_run_task(
|
||||
|
||||
env = os.environ.copy()
|
||||
env["ARTEMIS_SESSION_ID"] = trace_id
|
||||
env["ARTEMIS_TASK_INGRESS"] = "mcp"
|
||||
env[DeviceExecutionLock.QUEUE_TICKET_ENV] = queue_ticket
|
||||
|
||||
proc_kwargs = env_utils.get_detached_process_kwargs()
|
||||
proc = subprocess.Popen(
|
||||
@@ -172,11 +180,19 @@ def mobile_run_task(
|
||||
cwd=project_root,
|
||||
**proc_kwargs,
|
||||
)
|
||||
DeviceExecutionLock.transfer_reservation(
|
||||
queue_ticket,
|
||||
proc.pid,
|
||||
description=f"MCP task: {task_desc[:120]}",
|
||||
session_id=trace_id,
|
||||
ingress="mcp",
|
||||
)
|
||||
|
||||
# 5. Record the PID of the spawned subprocess
|
||||
status_data = trace_store.read_status(trace_id)
|
||||
if status_data:
|
||||
status_data["pid"] = proc.pid
|
||||
status_data["queue_ticket"] = queue_ticket
|
||||
trace_store.write_status(trace_id, status_data)
|
||||
|
||||
trace_dir = trace_store.get_trace_dir(trace_id)
|
||||
@@ -195,6 +211,7 @@ def mobile_run_task(
|
||||
return response_dict
|
||||
|
||||
except Exception as e:
|
||||
DeviceExecutionLock.cancel_reservation(queue_ticket)
|
||||
trace_store.update_trace_status(
|
||||
trace_id, "failed", error=f"Failed to spawn background runner: {e}"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from apps.admin_console.database.connection import db_session
|
||||
from apps.admin_console.database.repositories.session_repository import SessionRepository
|
||||
|
||||
|
||||
def _insert_running_session(db_path, session_id, pid):
|
||||
with db_session(db_path) as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO sessions "
|
||||
"(session_id, initial_goal, start_time, status, device_info, pid) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(session_id, session_id, 1.0, "running", "{}", pid),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def test_startup_cleanup_keeps_live_cross_process_session(tmp_path):
|
||||
db_path = tmp_path / "sessions.db"
|
||||
repository = SessionRepository(db_path)
|
||||
_insert_running_session(db_path, "live", 111)
|
||||
_insert_running_session(db_path, "dead", 222)
|
||||
|
||||
with patch.object(repository, "process_is_alive", side_effect=lambda pid: pid == 111):
|
||||
assert repository.cleanup_orphans_on_startup() == 1
|
||||
|
||||
assert repository.get_session_status("live") == "running"
|
||||
assert repository.get_session_status("dead") == "failed"
|
||||
|
||||
|
||||
def test_session_list_harvest_rechecks_worker_liveness(tmp_path):
|
||||
db_path = tmp_path / "sessions.db"
|
||||
repository = SessionRepository(db_path)
|
||||
_insert_running_session(db_path, "external-live", 333)
|
||||
|
||||
with patch.object(repository, "process_is_alive", return_value=True):
|
||||
assert repository.harvest_orphaned_sessions(["external-live"]) == 0
|
||||
|
||||
assert repository.get_session_status("external-live") == "running"
|
||||
@@ -0,0 +1,51 @@
|
||||
from apps.admin_console.core.state import ServerState
|
||||
|
||||
|
||||
def test_startup_progress_is_ordered_and_upserted_by_stage():
|
||||
state = ServerState()
|
||||
|
||||
state.record_startup_progress(
|
||||
{
|
||||
"session_id": "session-1",
|
||||
"stage": "queued",
|
||||
"message": "Task queued",
|
||||
"timestamp": 1.0,
|
||||
}
|
||||
)
|
||||
state.record_startup_progress(
|
||||
{
|
||||
"session_id": "session-1",
|
||||
"stage": "device",
|
||||
"message": "Checking device",
|
||||
"timestamp": 2.0,
|
||||
}
|
||||
)
|
||||
state.record_startup_progress(
|
||||
{
|
||||
"session_id": "session-1",
|
||||
"stage": "device",
|
||||
"message": "Device connected",
|
||||
"timestamp": 3.0,
|
||||
}
|
||||
)
|
||||
|
||||
events = state.get_startup_progress("session-1")
|
||||
assert [event["stage"] for event in events] == ["queued", "device"]
|
||||
assert events[-1]["message"] == "Device connected"
|
||||
|
||||
|
||||
def test_startup_progress_snapshot_is_not_mutable_by_callers():
|
||||
state = ServerState()
|
||||
state.record_startup_progress(
|
||||
{
|
||||
"session_id": "session-1",
|
||||
"stage": "queued",
|
||||
"message": "Task queued",
|
||||
"timestamp": 1.0,
|
||||
}
|
||||
)
|
||||
|
||||
snapshot = state.get_startup_progress("session-1")
|
||||
snapshot[0]["message"] = "changed"
|
||||
|
||||
assert state.get_startup_progress("session-1")[0]["message"] == "Task queued"
|
||||
@@ -0,0 +1,76 @@
|
||||
# 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 AsyncMock
|
||||
|
||||
from fastapi import HTTPException
|
||||
import pytest
|
||||
|
||||
from apps.admin_console.routers import tasks
|
||||
from apps.admin_console.schemas.task_schema import RunRequest
|
||||
from artemis.core.diagnostics.schema import (
|
||||
ProbeCategory,
|
||||
ProbeResult,
|
||||
ProbeStatus,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_task_rejects_locked_device(monkeypatch):
|
||||
locked_probe = ProbeResult(
|
||||
id="android_adb",
|
||||
category=ProbeCategory.DEVICE,
|
||||
title="Device / Emulator Connected",
|
||||
status=ProbeStatus.WARN,
|
||||
is_blocker=True,
|
||||
summary="Device Locked",
|
||||
description="Unlock the device.",
|
||||
)
|
||||
run_probe = AsyncMock(return_value=locked_probe)
|
||||
enqueue_tasks = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
tasks.readiness_engine, "run_device_submission_probe", run_probe
|
||||
)
|
||||
monkeypatch.setattr(tasks.task_queue_service, "enqueue_tasks", enqueue_tasks)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await tasks.run_task(RunRequest(goal="Open Settings"))
|
||||
|
||||
assert exc_info.value.status_code == 409
|
||||
assert "locked" in exc_info.value.detail.lower()
|
||||
enqueue_tasks.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_task_enqueues_when_device_is_unlocked(monkeypatch):
|
||||
unlocked_probe = ProbeResult(
|
||||
id="android_adb",
|
||||
category=ProbeCategory.DEVICE,
|
||||
title="Device / Emulator Connected",
|
||||
status=ProbeStatus.PASS,
|
||||
is_blocker=True,
|
||||
summary="Connected",
|
||||
description="Ready.",
|
||||
)
|
||||
run_probe = AsyncMock(return_value=unlocked_probe)
|
||||
enqueue_tasks = AsyncMock(return_value={"status": "started", "tasks": []})
|
||||
monkeypatch.setattr(
|
||||
tasks.readiness_engine, "run_device_submission_probe", run_probe
|
||||
)
|
||||
monkeypatch.setattr(tasks.task_queue_service, "enqueue_tasks", enqueue_tasks)
|
||||
|
||||
result = await tasks.run_task(RunRequest(goal="Open Settings"))
|
||||
|
||||
assert result["status"] == "started"
|
||||
enqueue_tasks.assert_awaited_once()
|
||||
@@ -22,7 +22,9 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import pytest
|
||||
|
||||
from apps.admin_console.core.state import state
|
||||
from apps.admin_console.routers.tasks import get_status
|
||||
from apps.admin_console.services.task_queue_service import TaskQueueService, task_queue_service
|
||||
from artemis.runtime.device_lock import DeviceLockOwner
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -39,13 +41,17 @@ def clean_state(tmp_path, monkeypatch):
|
||||
state.current_goal = None
|
||||
state.current_profile = None
|
||||
state.active_session_id = None
|
||||
state.active_connections.clear()
|
||||
state.was_stopped_manually = False
|
||||
state.cancelled_session_ids.clear()
|
||||
if state.worker_task and not state.worker_task.done():
|
||||
state.worker_task.cancel()
|
||||
state.worker_task = None
|
||||
yield
|
||||
state.clear_queue()
|
||||
state.queue_items.clear()
|
||||
state.active_connections.clear()
|
||||
state.cancelled_session_ids.clear()
|
||||
if state.worker_task and not state.worker_task.done():
|
||||
state.worker_task.cancel()
|
||||
state.worker_task = None
|
||||
@@ -135,11 +141,20 @@ async def test_stop_tasks_keep_remaining():
|
||||
"apps.admin_console.services.task_queue_service.process_supervisor.terminate_tree"
|
||||
) as mock_term,
|
||||
patch.object(TaskQueueService, "ensure_worker_running"),
|
||||
patch(
|
||||
"apps.admin_console.services.task_queue_service.session_repo.update_session_status"
|
||||
) as update_status,
|
||||
patch(
|
||||
"apps.admin_console.services.task_queue_service.session_repo.mark_all_running_cancelled"
|
||||
) as mark_all,
|
||||
):
|
||||
stopped = task_queue_service.stop_tasks(clear_all=False)
|
||||
assert stopped is True
|
||||
mock_proc.kill.assert_called_once()
|
||||
mock_term.assert_called_once_with(12345)
|
||||
update_status.assert_called_once()
|
||||
assert update_status.call_args.args[:2] == ("s1", "cancelled")
|
||||
mark_all.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -201,7 +216,7 @@ async def test_queue_worker_cmd_construction():
|
||||
):
|
||||
mock_repo.get_running_session_id.return_value = None
|
||||
|
||||
await task_queue_service.enqueue_tasks(
|
||||
enqueue_result = await task_queue_service.enqueue_tasks(
|
||||
["Test Goal with Outputter"],
|
||||
profile="pro",
|
||||
expected_output="Final summary",
|
||||
@@ -225,6 +240,10 @@ async def test_queue_worker_cmd_construction():
|
||||
assert "com.google.android.apps.maps" in cmd
|
||||
assert "--app-path" in cmd
|
||||
assert "/path/to/app.apk" in cmd
|
||||
assert (
|
||||
executed_kwargs[0]["env"]["ARTEMIS_DEVICE_QUEUE_TICKET"]
|
||||
== (enqueue_result["tasks"][0]["queue_ticket"])
|
||||
)
|
||||
if sys.platform == "win32":
|
||||
assert executed_kwargs[0]["creationflags"] == (
|
||||
subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW
|
||||
@@ -296,6 +315,136 @@ async def test_stop_tasks_dead_process_resets_state():
|
||||
assert len(state.queue_items) == 0
|
||||
|
||||
|
||||
def test_stop_tasks_terminates_external_global_owner_and_preserves_local_waiter():
|
||||
external_owner = DeviceLockOwner(
|
||||
pid=24680,
|
||||
process_created_at=1234.5,
|
||||
token="external-owner-token",
|
||||
device_id="emulator-5554",
|
||||
description="MCP task: inspect settings",
|
||||
acquired_at="2026-08-24T00:00:00+00:00",
|
||||
session_id="mcp-session",
|
||||
ingress="mcp",
|
||||
)
|
||||
local_waiter = MagicMock(pid=13579, returncode=None)
|
||||
state.current_process = local_waiter
|
||||
state.queue_items = [
|
||||
{
|
||||
"session_id": "frontend-waiter",
|
||||
"goal": "Run after MCP",
|
||||
"status": "running",
|
||||
"queue_ticket": "frontend-ticket",
|
||||
}
|
||||
]
|
||||
state.active_session_id = "mcp-session"
|
||||
state.active_connections["mcp-session"] = {"pid": 24680}
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
TaskQueueService,
|
||||
"ensure_worker_running",
|
||||
),
|
||||
patch(
|
||||
"apps.admin_console.services.task_queue_service.DeviceExecutionLock.get_active_owner",
|
||||
return_value=external_owner,
|
||||
),
|
||||
patch(
|
||||
"apps.admin_console.services.task_queue_service.DeviceExecutionLock.is_active_owner",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"apps.admin_console.services.task_queue_service.DeviceExecutionLock.cleanup_stale_locks"
|
||||
),
|
||||
patch(
|
||||
"apps.admin_console.services.task_queue_service.process_supervisor.terminate_tree_verified",
|
||||
return_value=True,
|
||||
) as terminate_verified,
|
||||
patch(
|
||||
"apps.admin_console.services.task_queue_service.session_repo.update_session_status"
|
||||
) as update_status,
|
||||
patch(
|
||||
"mcp_server.utils.trace_store.update_trace_status"
|
||||
) as update_trace_status,
|
||||
):
|
||||
assert task_queue_service.stop_tasks(clear_all=False) is True
|
||||
|
||||
terminate_verified.assert_called_once_with(24680, 1234.5)
|
||||
local_waiter.kill.assert_not_called()
|
||||
assert state.current_process is local_waiter
|
||||
assert state.queue_items[0]["session_id"] == "frontend-waiter"
|
||||
assert state.was_stopped_manually is False
|
||||
assert "frontend-waiter" not in state.cancelled_session_ids
|
||||
assert "mcp-session" not in state.active_connections
|
||||
update_status.assert_called_once()
|
||||
assert update_status.call_args.args[:2] == ("mcp-session", "cancelled")
|
||||
update_trace_status.assert_called_once_with(
|
||||
"mcp-session",
|
||||
"cancelled",
|
||||
error="Task stopped from the Artemis frontend.",
|
||||
)
|
||||
|
||||
|
||||
def test_stop_tasks_does_not_kill_stale_reused_pid():
|
||||
stale_owner = DeviceLockOwner(
|
||||
pid=24680,
|
||||
process_created_at=1234.5,
|
||||
token="stale-token",
|
||||
device_id="emulator-5554",
|
||||
description="CLI task",
|
||||
acquired_at="2026-08-24T00:00:00+00:00",
|
||||
session_id="cli-session",
|
||||
ingress="sdk",
|
||||
)
|
||||
with (
|
||||
patch(
|
||||
"apps.admin_console.services.task_queue_service.DeviceExecutionLock.get_active_owner",
|
||||
return_value=stale_owner,
|
||||
),
|
||||
patch(
|
||||
"apps.admin_console.services.task_queue_service.DeviceExecutionLock.is_active_owner",
|
||||
return_value=False,
|
||||
),
|
||||
patch(
|
||||
"apps.admin_console.services.task_queue_service.process_supervisor.terminate_tree_verified"
|
||||
) as terminate_verified,
|
||||
):
|
||||
assert task_queue_service.stop_tasks(clear_all=False) is False
|
||||
|
||||
terminate_verified.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_reports_external_global_owner_without_ipc_connection():
|
||||
external_owner = DeviceLockOwner(
|
||||
pid=24680,
|
||||
process_created_at=1234.5,
|
||||
token="external-owner-token",
|
||||
device_id="emulator-5554",
|
||||
description="CLI task: inspect settings",
|
||||
acquired_at="2026-08-24T00:00:00+00:00",
|
||||
session_id="cli-session",
|
||||
ingress="cli",
|
||||
)
|
||||
with (
|
||||
patch.object(TaskQueueService, "ensure_worker_running"),
|
||||
patch(
|
||||
"apps.admin_console.routers.tasks.DeviceExecutionLock.get_active_owner",
|
||||
return_value=external_owner,
|
||||
),
|
||||
patch("apps.admin_console.routers.tasks.session_repo") as repo,
|
||||
patch("apps.admin_console.routers.tasks.model_service") as models,
|
||||
):
|
||||
repo.get_latest_session.return_value = None
|
||||
repo.get_session_by_id.return_value = None
|
||||
models.get_active_model_info.return_value = None
|
||||
result = await get_status()
|
||||
|
||||
assert result["status"] == "running"
|
||||
assert result["session_id"] == "cli-session"
|
||||
assert result["pid"] == 24680
|
||||
assert result["goal"] == "CLI task: inspect settings"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_task_triggers_next_pending_task():
|
||||
executed_goals = []
|
||||
|
||||
@@ -97,6 +97,59 @@ async def test_validator_success(mock_mcp, mock_context, temp_screenshot):
|
||||
assert "attempts" not in report["execution"][0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_mcp_handshake_exits_contexts_and_disables_child_awake_policy():
|
||||
"""A handshake failure must not leave AnyIO cancellation scopes behind."""
|
||||
events = []
|
||||
|
||||
class FakeClientContext:
|
||||
async def __aenter__(self):
|
||||
events.append("client_enter")
|
||||
return Mock(), Mock()
|
||||
|
||||
async def __aexit__(self, exc_type, exc, traceback):
|
||||
events.append("client_exit")
|
||||
|
||||
class FakeSession:
|
||||
async def __aenter__(self):
|
||||
events.append("session_enter")
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, traceback):
|
||||
events.append("session_exit")
|
||||
|
||||
async def initialize(self):
|
||||
raise TimeoutError("handshake timed out")
|
||||
|
||||
ctx = Mock(spec=ArtemisContext)
|
||||
ctx.mcp_client_ctx = None
|
||||
ctx.mcp_session = None
|
||||
fake_client_ctx = FakeClientContext()
|
||||
fake_session = FakeSession()
|
||||
captured_parameters = []
|
||||
|
||||
def fake_stdio_client(parameters):
|
||||
captured_parameters.append(parameters)
|
||||
return fake_client_ctx
|
||||
|
||||
node = ValidatorNode(ctx)
|
||||
with (
|
||||
patch("artemis.agents.validator.validator.stdio_client", fake_stdio_client),
|
||||
patch("artemis.agents.validator.validator.ClientSession", return_value=fake_session),
|
||||
patch("artemis.agents.validator.validator.AdbClient") as adb_client,
|
||||
patch.object(node, "_kill_mcp_server_instantly") as emergency_kill,
|
||||
):
|
||||
adb_client.return_value.device_list.return_value = []
|
||||
result = await node._get_mcp_session()
|
||||
|
||||
assert result is None
|
||||
assert events == ["client_enter", "session_enter", "session_exit", "client_exit"]
|
||||
assert ctx.mcp_session is None
|
||||
assert ctx.mcp_client_ctx is None
|
||||
assert captured_parameters[0].env["ARTEMIS_KEEP_DEVICE_AWAKE"] == "false"
|
||||
emergency_kill.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validator_failure_analysis(mock_mcp, mock_context, temp_screenshot):
|
||||
"""Test that ValidatorNode triggers failure analysis on error and handles
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
|
||||
"""Unit tests for Artemis System Diagnostics & Readiness Engine."""
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from artemis.core.diagnostics.engine import ReadinessEngine
|
||||
from artemis.core.diagnostics.probes.adb_probe import AdbDeviceProbe
|
||||
@@ -111,6 +113,83 @@ async def test_adb_probe_structure():
|
||||
assert "installed" in result.metadata
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("policy_output", "trust_output", "expected"),
|
||||
[
|
||||
(
|
||||
"KeyguardServiceDelegate\n showing=true\n occluded=false\n",
|
||||
'User "Owner" (current): deviceLocked=1',
|
||||
True,
|
||||
),
|
||||
(
|
||||
"KeyguardServiceDelegate\n showing=false\n occluded=false\n",
|
||||
'User "Owner" (current): deviceLocked=0',
|
||||
False,
|
||||
),
|
||||
(
|
||||
"mShowingLockscreen=true mKeyguardOccluded=false",
|
||||
"",
|
||||
True,
|
||||
),
|
||||
("", "", None),
|
||||
],
|
||||
)
|
||||
def test_adb_probe_parses_device_lock_state(policy_output, trust_output, expected):
|
||||
"""Keyguard and current-user trust signals produce a fail-safe lock state."""
|
||||
assert AdbDeviceProbe._parse_device_lock_state(policy_output, trust_output) is expected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_submission_probe_skips_full_device_enrichment(monkeypatch):
|
||||
probe = AdbDeviceProbe(target_serial="device-2")
|
||||
get_states = AsyncMock(
|
||||
return_value=[("device-1", "device"), ("device-2", "device")]
|
||||
)
|
||||
get_lock_state = AsyncMock(return_value=False)
|
||||
full_probe = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
"artemis.core.diagnostics.probes.adb_probe.toolchain.resolve",
|
||||
lambda name: "adb",
|
||||
)
|
||||
monkeypatch.setattr(probe, "_get_device_states", get_states)
|
||||
monkeypatch.setattr(probe, "_get_device_lock_state", get_lock_state)
|
||||
monkeypatch.setattr(probe, "_parse_adb_devices", full_probe)
|
||||
|
||||
result = await probe.probe_submission_readiness()
|
||||
|
||||
assert result.summary == "Connected"
|
||||
assert result.metadata["submission_probe"] is True
|
||||
get_states.assert_awaited_once_with("adb")
|
||||
get_lock_state.assert_awaited_once_with(
|
||||
"adb", "device-2", timeout_seconds=1.0
|
||||
)
|
||||
full_probe.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_submission_probe_fails_closed_when_lock_state_is_unknown(monkeypatch):
|
||||
probe = AdbDeviceProbe()
|
||||
monkeypatch.setattr(
|
||||
"artemis.core.diagnostics.probes.adb_probe.toolchain.resolve",
|
||||
lambda name: "adb",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
probe,
|
||||
"_get_device_states",
|
||||
AsyncMock(return_value=[("device-1", "device")]),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
probe,
|
||||
"_get_device_lock_state",
|
||||
AsyncMock(return_value=None),
|
||||
)
|
||||
|
||||
result = await probe.probe_submission_readiness()
|
||||
|
||||
assert result.summary == "Lock State Unknown"
|
||||
assert result.status == ProbeStatus.WARN
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_credentials_probe_structure():
|
||||
"""Verify LLMCredentialsProbe returns correct category and schema."""
|
||||
|
||||
@@ -72,6 +72,37 @@ def test_mobile_run_task_invalid_model():
|
||||
mobile_run_task(task_desc="test", conversation_id="conv-1", model="invalid_model")
|
||||
|
||||
|
||||
def test_mobile_run_task_reserves_and_passes_global_queue_ticket(temp_trace_env):
|
||||
process = MagicMock(pid=43210)
|
||||
with (
|
||||
patch(
|
||||
"mcp_server.tools.task_runner.DeviceExecutionLock.reserve",
|
||||
return_value="queue-ticket-1",
|
||||
) 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",
|
||||
conversation_id="conv-1",
|
||||
model="Flash",
|
||||
)
|
||||
|
||||
reserve.assert_called_once()
|
||||
transfer.assert_called_once_with(
|
||||
"queue-ticket-1",
|
||||
43210,
|
||||
description="MCP task: Open Settings",
|
||||
session_id=result["trace_id"],
|
||||
ingress="mcp",
|
||||
)
|
||||
assert result["trace_id"]
|
||||
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"
|
||||
|
||||
|
||||
def test_mobile_manage_task_unknown_trace(temp_trace_env):
|
||||
res = mobile_manage_task(action="status", trace_id="non-existent-trace")
|
||||
assert res["status"] == "unknown"
|
||||
@@ -154,6 +185,7 @@ async def test_mobile_inspect_trace_invalid_action():
|
||||
async def test_mcp_server_auto_registers_tools():
|
||||
"""Verify that importing mcp_server.base auto-registers all mobile tools without manual tools import."""
|
||||
from mcp_server.base import mcp
|
||||
|
||||
tools = await mcp.list_tools()
|
||||
tool_names = {t.name for t in tools}
|
||||
assert {
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
# 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 json
|
||||
|
||||
import pytest
|
||||
|
||||
from artemis.runtime.awake_lease import ScreenAwakeLease
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolated_awake_directory(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"artemis.runtime.awake_lease.get_temp_dir",
|
||||
lambda _name: tmp_path,
|
||||
)
|
||||
|
||||
|
||||
def test_first_lease_drains_legacy_references_and_acquires_exactly_one(monkeypatch):
|
||||
lease = ScreenAwakeLease("device-123")
|
||||
count = 3
|
||||
|
||||
def state():
|
||||
return (count > 0, count)
|
||||
|
||||
def set_lock(action):
|
||||
nonlocal count
|
||||
count += 1 if action == "acquire" else -1
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(lease, "_wake_lock_state", state)
|
||||
monkeypatch.setattr(lease, "_set_wake_lock", set_lock)
|
||||
|
||||
assert lease.acquire()
|
||||
assert count == 1
|
||||
assert lease.lease_path.exists()
|
||||
|
||||
lease.release()
|
||||
assert count == 0
|
||||
|
||||
|
||||
def test_overlapping_clients_share_one_android_reference(monkeypatch):
|
||||
first = ScreenAwakeLease("device-123")
|
||||
second = ScreenAwakeLease("device-123")
|
||||
count = 0
|
||||
|
||||
def state():
|
||||
return (count > 0, count)
|
||||
|
||||
def set_lock(action):
|
||||
nonlocal count
|
||||
count += 1 if action == "acquire" else -1
|
||||
return True
|
||||
|
||||
for lease in (first, second):
|
||||
monkeypatch.setattr(lease, "_wake_lock_state", state)
|
||||
monkeypatch.setattr(lease, "_set_wake_lock", set_lock)
|
||||
|
||||
assert first.acquire()
|
||||
assert second.acquire()
|
||||
assert count == 1
|
||||
|
||||
first.release()
|
||||
assert count == 1
|
||||
second.release()
|
||||
assert count == 0
|
||||
|
||||
|
||||
def test_crashed_client_is_pruned_and_legacy_reference_is_recovered(monkeypatch):
|
||||
crashed = ScreenAwakeLease("device-123")
|
||||
crashed.lease_dir.mkdir(parents=True, exist_ok=True)
|
||||
crashed.lease_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"pid": 999999,
|
||||
"process_created_at": 1.0,
|
||||
"token": crashed.token,
|
||||
"device_id": "device-123",
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
replacement = ScreenAwakeLease("device-123")
|
||||
count = 1
|
||||
|
||||
def state():
|
||||
return (count > 0, count)
|
||||
|
||||
def set_lock(action):
|
||||
nonlocal count
|
||||
count += 1 if action == "acquire" else -1
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(replacement, "_wake_lock_state", state)
|
||||
monkeypatch.setattr(replacement, "_set_wake_lock", set_lock)
|
||||
monkeypatch.setattr(
|
||||
ScreenAwakeLease,
|
||||
"_owner_is_alive",
|
||||
staticmethod(lambda payload: int(payload["pid"]) != 999999),
|
||||
)
|
||||
|
||||
assert replacement.acquire()
|
||||
assert not crashed.lease_path.exists()
|
||||
assert count == 1
|
||||
|
||||
replacement.release()
|
||||
assert count == 0
|
||||
|
||||
|
||||
def test_migration_cleanup_never_drains_a_live_legacy_owner(monkeypatch):
|
||||
owner = ScreenAwakeLease("device-123")
|
||||
owner.lease_dir.mkdir(parents=True, exist_ok=True)
|
||||
owner.lease_path.write_text(json.dumps(owner._payload()), encoding="utf-8")
|
||||
cleanup = ScreenAwakeLease("device-123")
|
||||
cleanup._drain_references = lambda: pytest.fail("must not drain a live owner")
|
||||
|
||||
assert cleanup.cleanup_unowned_references() is False
|
||||
@@ -0,0 +1,166 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from artemis.runtime.awake_service import (
|
||||
AWAKE_STRATEGY_HEARTBEAT,
|
||||
AWAKE_STRATEGY_USB,
|
||||
ScreenAwakeService,
|
||||
_configure_usb_stay_awake,
|
||||
_discover_connected_device_ids,
|
||||
)
|
||||
|
||||
|
||||
def completed(stdout="", returncode=0, stderr=""):
|
||||
return MagicMock(returncode=returncode, stdout=stdout, stderr=stderr)
|
||||
|
||||
|
||||
@patch("artemis.runtime.awake_service.ScreenAwakeLease")
|
||||
@patch("artemis.runtime.awake_service.subprocess.run")
|
||||
def test_usb_policy_is_primary_when_android_reports_it_active(mock_run, lease_type):
|
||||
mock_run.side_effect = [
|
||||
completed(),
|
||||
completed(),
|
||||
completed(),
|
||||
completed("2\n"),
|
||||
completed(" mIsPowered=true\n mPlugType=2\n mStayOn=true\n"),
|
||||
]
|
||||
|
||||
assert _configure_usb_stay_awake("device-123") == AWAKE_STRATEGY_USB
|
||||
|
||||
lease_type.assert_called_once_with("device-123")
|
||||
lease_type.return_value.cleanup_unowned_references.assert_called_once_with()
|
||||
commands = [call.args[0] for call in mock_run.call_args_list]
|
||||
assert ["shell", "svc", "power", "stayon", "usb"] in [command[3:] for command in commands]
|
||||
assert not any("SCREEN_BRIGHT_WAKE_LOCK" in command for command in commands)
|
||||
assert not any("KEYCODE_UNKNOWN" in command for command in commands)
|
||||
|
||||
|
||||
@patch("artemis.runtime.awake_service.ScreenAwakeLease")
|
||||
@patch("artemis.runtime.awake_service.subprocess.run")
|
||||
def test_inactive_usb_policy_falls_back_to_effective_host_heartbeat(mock_run, _lease_type):
|
||||
mock_run.side_effect = [
|
||||
completed(),
|
||||
completed(),
|
||||
completed(),
|
||||
completed("2\n"),
|
||||
completed(" mIsPowered=true\n mPlugType=2\n mStayOn=false\n"),
|
||||
completed(),
|
||||
]
|
||||
|
||||
assert _configure_usb_stay_awake("device-123") == AWAKE_STRATEGY_HEARTBEAT
|
||||
|
||||
assert mock_run.call_args_list[-1].args[0][-1] == "KEYCODE_UNKNOWN"
|
||||
|
||||
|
||||
@patch.dict("os.environ", {"ARTEMIS_KEEP_DEVICE_AWAKE": "false"})
|
||||
@patch("artemis.runtime.awake_service.subprocess.run")
|
||||
def test_awake_strategy_can_be_disabled(mock_run):
|
||||
assert _configure_usb_stay_awake("device-123") is None
|
||||
mock_run.assert_not_called()
|
||||
|
||||
|
||||
@patch("artemis.runtime.awake_service.threading.Thread")
|
||||
@patch("artemis.runtime.awake_service.threading.Event")
|
||||
@patch(
|
||||
"artemis.runtime.awake_service._configure_usb_stay_awake",
|
||||
return_value=AWAKE_STRATEGY_HEARTBEAT,
|
||||
)
|
||||
def test_host_heartbeat_lives_until_host_shutdown(configure, event_type, thread_type):
|
||||
service = ScreenAwakeService()
|
||||
stop_event = event_type.return_value
|
||||
thread = thread_type.return_value
|
||||
|
||||
service.ensure_device("device-123")
|
||||
service.ensure_device("device-123")
|
||||
|
||||
configure.assert_called_once_with("device-123")
|
||||
thread.start.assert_called_once_with()
|
||||
stop_event.set.assert_not_called()
|
||||
|
||||
service.shutdown()
|
||||
service.shutdown()
|
||||
|
||||
stop_event.set.assert_called_once_with()
|
||||
thread.join.assert_called_once_with(timeout=2.0)
|
||||
|
||||
|
||||
@patch("artemis.runtime.awake_service._run_awake_adb_command")
|
||||
@patch("artemis.runtime.awake_service.threading.Thread")
|
||||
@patch("artemis.runtime.awake_service.threading.Event")
|
||||
@patch(
|
||||
"artemis.runtime.awake_service._configure_usb_stay_awake",
|
||||
return_value=AWAKE_STRATEGY_HEARTBEAT,
|
||||
)
|
||||
def test_heartbeat_loop_uses_verified_user_activity_key(
|
||||
_configure, event_type, thread_type, run_command
|
||||
):
|
||||
stop_event = event_type.return_value
|
||||
stop_event.wait.side_effect = [False, True]
|
||||
service = ScreenAwakeService()
|
||||
|
||||
service.ensure_device("device-123")
|
||||
heartbeat_target = thread_type.call_args.kwargs["target"]
|
||||
heartbeat_target()
|
||||
|
||||
run_command.assert_called_once_with(
|
||||
"device-123",
|
||||
["shell", "input", "keyevent", "KEYCODE_UNKNOWN"],
|
||||
"send the host stay-awake heartbeat",
|
||||
)
|
||||
|
||||
|
||||
@patch("artemis.runtime.awake_service.threading.Thread")
|
||||
@patch("artemis.runtime.awake_service.threading.Event")
|
||||
@patch("artemis.runtime.awake_service._discover_connected_device_ids")
|
||||
@patch(
|
||||
"artemis.runtime.awake_service._configure_usb_stay_awake",
|
||||
return_value=AWAKE_STRATEGY_USB,
|
||||
)
|
||||
def test_service_monitor_enrolls_a_device_attached_after_start(
|
||||
configure, discover, event_type, thread_type
|
||||
):
|
||||
discover.side_effect = [[], ["device-late"]]
|
||||
stop_event = event_type.return_value
|
||||
stop_event.wait.side_effect = [False, True]
|
||||
stop_event.is_set.return_value = False
|
||||
service = ScreenAwakeService()
|
||||
|
||||
service.start()
|
||||
monitor_target = thread_type.call_args.kwargs["target"]
|
||||
monitor_target()
|
||||
|
||||
configure.assert_called_once_with("device-late")
|
||||
assert service.device_ids == ("device-late",)
|
||||
|
||||
|
||||
@patch("artemis.runtime.awake_service.threading.Thread")
|
||||
@patch("artemis.runtime.awake_service.threading.Event")
|
||||
@patch(
|
||||
"artemis.runtime.awake_service._configure_usb_stay_awake",
|
||||
return_value=AWAKE_STRATEGY_HEARTBEAT,
|
||||
)
|
||||
def test_disconnect_reconciliation_stops_heartbeat_and_allows_reenrollment(
|
||||
configure, event_type, thread_type
|
||||
):
|
||||
service = ScreenAwakeService()
|
||||
stop_event = event_type.return_value
|
||||
thread = thread_type.return_value
|
||||
service.ensure_device("device-123")
|
||||
|
||||
service._reconcile_connected_devices(set())
|
||||
|
||||
stop_event.set.assert_called_once_with()
|
||||
thread.join.assert_called_once_with(timeout=2.0)
|
||||
assert service.device_ids == ()
|
||||
|
||||
service.ensure_device("device-123")
|
||||
assert configure.call_count == 2
|
||||
|
||||
|
||||
@patch("artemis.runtime.awake_service.AdbClient")
|
||||
def test_discovery_honors_single_device_policy(adb_client):
|
||||
adb_client.return_value.device_list.return_value = [
|
||||
MagicMock(serial="device-1"),
|
||||
MagicMock(serial="device-2"),
|
||||
]
|
||||
|
||||
assert _discover_connected_device_ids() == ["device-1"]
|
||||
@@ -13,6 +13,8 @@
|
||||
# limitations under the License.
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -34,7 +36,7 @@ def test_device_lock_blocks_a_second_process_owner():
|
||||
first.acquire()
|
||||
try:
|
||||
with pytest.raises(DeviceBusyError, match="first task"):
|
||||
second.acquire()
|
||||
second.acquire(blocking=False)
|
||||
finally:
|
||||
first.release()
|
||||
|
||||
@@ -79,9 +81,9 @@ def test_device_lock_release_does_not_remove_replacement_owner():
|
||||
|
||||
def test_cleanup_stale_locks_keeps_live_owner(monkeypatch):
|
||||
live = DeviceExecutionLock("emulator-5554", "live task")
|
||||
stale = DeviceExecutionLock("physical-device", "stale task")
|
||||
live.acquire()
|
||||
stale.path.write_text(
|
||||
stale_path = live.path.parent / "stale-device.lock"
|
||||
stale_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"pid": 999999,
|
||||
@@ -105,6 +107,123 @@ def test_cleanup_stale_locks_keeps_live_owner(monkeypatch):
|
||||
try:
|
||||
assert DeviceExecutionLock.cleanup_stale_locks() == 1
|
||||
assert live.path.exists()
|
||||
assert not stale.path.exists()
|
||||
assert not stale_path.exists()
|
||||
finally:
|
||||
live.release()
|
||||
|
||||
|
||||
def test_device_lock_waits_and_runs_next_owner_in_fifo_order():
|
||||
first = DeviceExecutionLock("emulator-5554", "first task")
|
||||
second = DeviceExecutionLock("emulator-5554", "second task")
|
||||
acquired = threading.Event()
|
||||
|
||||
first.acquire()
|
||||
|
||||
def acquire_second():
|
||||
second.acquire()
|
||||
acquired.set()
|
||||
|
||||
thread = threading.Thread(target=acquire_second)
|
||||
thread.start()
|
||||
time.sleep(0.15)
|
||||
assert not acquired.is_set()
|
||||
|
||||
first.release()
|
||||
assert acquired.wait(timeout=2.0)
|
||||
second.release()
|
||||
thread.join(timeout=2.0)
|
||||
|
||||
|
||||
def test_submission_reservations_preserve_order_before_workers_start():
|
||||
first_ticket = DeviceExecutionLock.reserve("first submitted task")
|
||||
second_ticket = DeviceExecutionLock.reserve("second submitted task")
|
||||
second = DeviceExecutionLock("emulator-5554", "second task", second_ticket)
|
||||
second_acquired = threading.Event()
|
||||
|
||||
def acquire_second():
|
||||
second.acquire()
|
||||
second_acquired.set()
|
||||
|
||||
thread = threading.Thread(target=acquire_second)
|
||||
thread.start()
|
||||
time.sleep(0.15)
|
||||
assert not second_acquired.is_set()
|
||||
|
||||
first = DeviceExecutionLock("emulator-5554", "first task", first_ticket)
|
||||
first.acquire()
|
||||
first.release()
|
||||
|
||||
assert second_acquired.wait(timeout=2.0)
|
||||
second.release()
|
||||
thread.join(timeout=2.0)
|
||||
|
||||
|
||||
def test_cancelled_reservation_cannot_execute():
|
||||
ticket = DeviceExecutionLock.reserve("cancelled task")
|
||||
assert DeviceExecutionLock.cancel_reservation(ticket)
|
||||
|
||||
lock = DeviceExecutionLock("emulator-5554", "cancelled task", ticket)
|
||||
with pytest.raises(DeviceBusyError, match="cancelled or expired"):
|
||||
lock.acquire()
|
||||
|
||||
|
||||
def test_reservation_can_be_transferred_to_worker_process():
|
||||
ticket = DeviceExecutionLock.reserve("submitted task")
|
||||
|
||||
assert DeviceExecutionLock.transfer_reservation(ticket, 4242, description="worker task")
|
||||
|
||||
path = next((DeviceExecutionLock("device").queue_dir).glob(f"*-{ticket}.wait"))
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
assert payload["pid"] == 4242
|
||||
assert payload["description"] == "worker task"
|
||||
|
||||
|
||||
def test_waiting_acquire_can_be_cancelled_without_leaving_a_ticket():
|
||||
owner = DeviceExecutionLock("emulator-5554", "owner")
|
||||
waiter = DeviceExecutionLock("emulator-5554", "waiter")
|
||||
cancel_event = threading.Event()
|
||||
errors = []
|
||||
owner.acquire()
|
||||
|
||||
def wait_for_device():
|
||||
try:
|
||||
waiter.acquire(cancel_event=cancel_event)
|
||||
except DeviceBusyError as exc:
|
||||
errors.append(str(exc))
|
||||
|
||||
thread = threading.Thread(target=wait_for_device)
|
||||
thread.start()
|
||||
time.sleep(0.15)
|
||||
cancel_event.set()
|
||||
thread.join(timeout=2.0)
|
||||
|
||||
try:
|
||||
assert errors == ["Waiting for the Artemis device queue was cancelled."]
|
||||
assert list(waiter.queue_dir.glob("*.wait")) == []
|
||||
finally:
|
||||
owner.release()
|
||||
|
||||
|
||||
def test_active_owner_is_discoverable_and_can_be_annotated(monkeypatch):
|
||||
monkeypatch.setenv("ARTEMIS_TASK_INGRESS", "cli")
|
||||
monkeypatch.delenv("ARTEMIS_SESSION_ID", raising=False)
|
||||
owner_lock = DeviceExecutionLock("emulator-5554", "CLI task")
|
||||
owner_lock.acquire()
|
||||
try:
|
||||
owner = DeviceExecutionLock.get_active_owner()
|
||||
assert owner is not None
|
||||
assert owner.pid == owner_lock._read_owner(owner_lock.path).pid
|
||||
assert owner.ingress == "cli"
|
||||
assert owner.session_id is None
|
||||
assert DeviceExecutionLock.is_active_owner(owner)
|
||||
|
||||
assert DeviceExecutionLock.annotate_active_owner(
|
||||
session_id="cli-session",
|
||||
ingress="cli",
|
||||
)
|
||||
annotated = DeviceExecutionLock.get_active_owner()
|
||||
assert annotated is not None
|
||||
assert annotated.session_id == "cli-session"
|
||||
assert annotated.ingress == "cli"
|
||||
finally:
|
||||
owner_lock.release()
|
||||
@@ -0,0 +1,27 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from artemis.runtime.supervisor import ProcessSupervisor
|
||||
|
||||
|
||||
def test_verified_termination_rejects_reused_pid():
|
||||
process = MagicMock()
|
||||
process.create_time.return_value = 200.0
|
||||
with (
|
||||
patch("psutil.Process", return_value=process),
|
||||
patch.object(ProcessSupervisor, "terminate_tree") as terminate_tree,
|
||||
):
|
||||
assert ProcessSupervisor.terminate_tree_verified(1234, 100.0) is False
|
||||
|
||||
terminate_tree.assert_not_called()
|
||||
|
||||
|
||||
def test_verified_termination_stops_matching_process_tree():
|
||||
process = MagicMock()
|
||||
process.create_time.return_value = 100.0
|
||||
with (
|
||||
patch("psutil.Process", return_value=process),
|
||||
patch.object(ProcessSupervisor, "terminate_tree", return_value=True) as terminate_tree,
|
||||
):
|
||||
assert ProcessSupervisor.terminate_tree_verified(1234, 100.0) is True
|
||||
|
||||
terminate_tree.assert_called_once_with(1234, timeout_seconds=3.0)
|
||||
@@ -12,11 +12,14 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from artemis.sdk.agent import Agent
|
||||
from artemis.context import DeviceContext, DevicePlatform
|
||||
from artemis.runtime.device_lock import DeviceBusyError
|
||||
from artemis.sdk.types.exceptions import AgentError
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -30,3 +33,83 @@ async def test_trace_finalization_failure_does_not_escape():
|
||||
await agent._finalize_tracing_safely(task=task, context=context)
|
||||
|
||||
agent._finalize_tracing.assert_awaited_once_with(task=task, context=context)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_clean_disconnects_the_shared_ui_client():
|
||||
agent = object.__new__(Agent)
|
||||
agent._initialized = True
|
||||
agent._ui_adb_client = MagicMock()
|
||||
|
||||
await agent.clean()
|
||||
|
||||
agent._ui_adb_client.disconnect.assert_called_once_with()
|
||||
assert agent._initialized is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_device_context_uses_adb_size_without_starting_ui_client():
|
||||
agent = object.__new__(Agent)
|
||||
agent._adb_client = MagicMock()
|
||||
agent._adb_client.device.return_value.window_size.return_value = (1080, 2424)
|
||||
agent._ui_adb_client = MagicMock()
|
||||
|
||||
from artemis.context import DevicePlatform
|
||||
|
||||
context = await agent._get_device_context("device-123", DevicePlatform.ANDROID)
|
||||
|
||||
assert context.device_width == 1080
|
||||
assert context.device_height == 2424
|
||||
agent._ui_adb_client.get_screen_data.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_task_that_never_acquires_queue_does_not_create_trace_session():
|
||||
agent = Agent()
|
||||
agent._initialized = True
|
||||
agent._device_context = DeviceContext(
|
||||
host_platform="WINDOWS",
|
||||
mobile_platform=DevicePlatform.ANDROID,
|
||||
device_id="device-123",
|
||||
device_width=1080,
|
||||
device_height=2424,
|
||||
)
|
||||
agent._adb_client = MagicMock()
|
||||
agent._ui_adb_client = MagicMock()
|
||||
agent._prepare_tracing = MagicMock()
|
||||
|
||||
with patch(
|
||||
"artemis.sdk.agent.DeviceExecutionLock.acquire",
|
||||
side_effect=DeviceBusyError("queue cancelled"),
|
||||
):
|
||||
with pytest.raises(DeviceBusyError, match="queue cancelled"):
|
||||
await agent.run_task(goal="queued task", profile="flash")
|
||||
|
||||
agent._prepare_tracing.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_secure_keyguard_is_rejected_without_guessing_credentials():
|
||||
agent = object.__new__(Agent)
|
||||
agent._device_context = MagicMock(device_id="device-123")
|
||||
agent._adb_client = MagicMock()
|
||||
agent._adb_client.device.return_value.shell.return_value = (
|
||||
'User "Owner" (id=0): deviceLocked=1\n'
|
||||
'User "Work" (id=10): deviceLocked=1\n'
|
||||
)
|
||||
|
||||
with pytest.raises(AgentError, match="Unlock the device manually"):
|
||||
await agent._ensure_device_unlocked()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_locked_work_profile_does_not_block_unlocked_device_owner():
|
||||
agent = object.__new__(Agent)
|
||||
agent._device_context = MagicMock(device_id="device-123")
|
||||
agent._adb_client = MagicMock()
|
||||
agent._adb_client.device.return_value.shell.return_value = (
|
||||
'User "Owner" (id=0): deviceLocked=0\n'
|
||||
'User "Work" (id=10): deviceLocked=1\n'
|
||||
)
|
||||
|
||||
await agent._ensure_device_unlocked()
|
||||
@@ -1,8 +1,39 @@
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from artemis.services import llm
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_wrapper_cancels_llm_call_when_caller_is_cancelled():
|
||||
started = asyncio.Event()
|
||||
child_cancelled = asyncio.Event()
|
||||
|
||||
async def pending_llm_call():
|
||||
started.set()
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
except asyncio.CancelledError:
|
||||
child_cancelled.set()
|
||||
raise
|
||||
|
||||
invocation = asyncio.create_task(
|
||||
llm.invoke_llm_with_timeout_message(
|
||||
pending_llm_call(),
|
||||
timeout_seconds=60,
|
||||
)
|
||||
)
|
||||
await started.wait()
|
||||
invocation.cancel()
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await invocation
|
||||
|
||||
assert child_cancelled.is_set()
|
||||
|
||||
|
||||
class _RecordingEngine:
|
||||
current_step_id = "step-7"
|
||||
current_session_id = "session-1"
|
||||
|
||||
@@ -2,140 +2,64 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from artemis.clients.ui_automator_client import (
|
||||
AWAKE_STRATEGY_HEARTBEAT,
|
||||
AWAKE_STRATEGY_STAY_ON,
|
||||
AWAKE_STRATEGY_WAKE_LOCK,
|
||||
UIAutomatorClient,
|
||||
_keep_device_awake,
|
||||
)
|
||||
from artemis.clients.ui_automator_client import UIAutomatorClient
|
||||
from artemis.drivers.android.adb_driver import AndroidAdbDriver
|
||||
|
||||
|
||||
def completed(stdout="", returncode=0, stderr=""):
|
||||
return MagicMock(returncode=returncode, stdout=stdout, stderr=stderr)
|
||||
|
||||
|
||||
@patch("artemis.clients.ui_automator_client.subprocess.run")
|
||||
def test_keep_device_awake_uses_verified_primary_strategy(mock_run):
|
||||
mock_run.side_effect = [
|
||||
completed(),
|
||||
completed(),
|
||||
completed(),
|
||||
completed(" mStayOn=true\n"),
|
||||
]
|
||||
|
||||
strategy = _keep_device_awake("device-123")
|
||||
|
||||
assert strategy == AWAKE_STRATEGY_STAY_ON
|
||||
assert mock_run.call_count == 4
|
||||
|
||||
|
||||
@patch("artemis.clients.ui_automator_client.subprocess.run")
|
||||
def test_keep_device_awake_falls_back_to_verified_screen_wake_lock(mock_run):
|
||||
mock_run.side_effect = [
|
||||
completed(),
|
||||
completed(),
|
||||
completed(),
|
||||
completed(" mStayOn=false\n"),
|
||||
completed("WakeLock{abc held=true, refCount=1}\n"),
|
||||
completed(
|
||||
"Display 0, wakelock type: SCREEN_BRIGHT_WAKE_LOCK: "
|
||||
"WakeLock{abc held=true, refCount=1}\n"
|
||||
),
|
||||
]
|
||||
|
||||
strategy = _keep_device_awake("device-123")
|
||||
|
||||
assert strategy == AWAKE_STRATEGY_WAKE_LOCK
|
||||
assert mock_run.call_args_list[-2].args[0] == [
|
||||
"adb",
|
||||
"-s",
|
||||
"device-123",
|
||||
"shell",
|
||||
"cmd",
|
||||
"power",
|
||||
"set-wakelock",
|
||||
"acquire",
|
||||
"-d",
|
||||
"0",
|
||||
"SCREEN_BRIGHT_WAKE_LOCK",
|
||||
]
|
||||
|
||||
|
||||
@patch("artemis.clients.ui_automator_client.subprocess.run")
|
||||
def test_keep_device_awake_uses_heartbeat_when_wake_lock_is_unavailable(mock_run):
|
||||
mock_run.side_effect = [
|
||||
completed(),
|
||||
completed(),
|
||||
completed(),
|
||||
completed(" mStayOn=false\n"),
|
||||
completed(returncode=1, stderr="unknown command"),
|
||||
completed("Wakelocks:\n"),
|
||||
]
|
||||
|
||||
strategy = _keep_device_awake("device-123")
|
||||
|
||||
assert strategy == AWAKE_STRATEGY_HEARTBEAT
|
||||
|
||||
|
||||
@patch.dict("os.environ", {"ARTEMIS_KEEP_DEVICE_AWAKE": "false"})
|
||||
@patch("artemis.clients.ui_automator_client.subprocess.run")
|
||||
def test_keep_device_awake_can_be_disabled(mock_run):
|
||||
assert _keep_device_awake("device-123") is None
|
||||
mock_run.assert_not_called()
|
||||
|
||||
|
||||
@patch("artemis.clients.ui_automator_client.u2.connect")
|
||||
@patch(
|
||||
"artemis.clients.ui_automator_client._keep_device_awake",
|
||||
return_value=AWAKE_STRATEGY_HEARTBEAT,
|
||||
"artemis.clients.ui_automator_client.ensure_device_awake",
|
||||
return_value="host_heartbeat",
|
||||
)
|
||||
@patch("artemis.clients.ui_automator_client._ensure_maestro_not_installed")
|
||||
def test_new_ui_connection_starts_heartbeat_fallback(
|
||||
mock_remove_maestro, mock_keep_awake, mock_connect
|
||||
def test_new_ui_connection_enrolls_device_in_shared_awake_strategy(
|
||||
mock_remove_maestro, mock_ensure_awake, mock_connect
|
||||
):
|
||||
client = UIAutomatorClient("device-123")
|
||||
client._start_awake_heartbeat = MagicMock()
|
||||
|
||||
client._ensure_connected()
|
||||
client.connect()
|
||||
client.connect()
|
||||
|
||||
mock_remove_maestro.assert_called_once_with("device-123")
|
||||
mock_keep_awake.assert_called_once_with("device-123")
|
||||
mock_ensure_awake.assert_called_once_with("device-123")
|
||||
mock_connect.assert_called_once_with("device-123")
|
||||
client._start_awake_heartbeat.assert_called_once_with()
|
||||
assert client._awake_strategy == AWAKE_STRATEGY_HEARTBEAT
|
||||
|
||||
|
||||
@patch("artemis.clients.ui_automator_client._release_screen_wake_lock")
|
||||
def test_disconnect_releases_screen_wake_lock(mock_release):
|
||||
@patch("artemis.clients.ui_automator_client.u2.connect", side_effect=RuntimeError("offline"))
|
||||
@patch(
|
||||
"artemis.clients.ui_automator_client.ensure_device_awake",
|
||||
return_value="host_heartbeat",
|
||||
)
|
||||
@patch("artemis.clients.ui_automator_client._ensure_maestro_not_installed")
|
||||
def test_failed_ui_connection_does_not_stop_process_awake_service(
|
||||
_mock_remove_maestro, mock_ensure_awake, _mock_connect
|
||||
):
|
||||
client = UIAutomatorClient("device-123")
|
||||
client._awake_strategy = AWAKE_STRATEGY_WAKE_LOCK
|
||||
client._stop_awake_heartbeat = MagicMock()
|
||||
|
||||
client.disconnect()
|
||||
with pytest.raises(RuntimeError, match="offline"):
|
||||
client.connect()
|
||||
|
||||
client._stop_awake_heartbeat.assert_called_once_with()
|
||||
mock_release.assert_called_once_with("device-123")
|
||||
mock_ensure_awake.assert_called_once_with("device-123")
|
||||
assert client._awake_strategy is None
|
||||
|
||||
|
||||
@patch("artemis.clients.ui_automator_client.threading.Thread")
|
||||
@patch("artemis.clients.ui_automator_client.threading.Event")
|
||||
def test_heartbeat_thread_is_daemonized_and_stoppable(mock_event, mock_thread):
|
||||
stop_event = MagicMock()
|
||||
thread = MagicMock()
|
||||
mock_event.return_value = stop_event
|
||||
mock_thread.return_value = thread
|
||||
@patch("artemis.clients.ui_automator_client.u2.connect")
|
||||
@patch(
|
||||
"artemis.clients.ui_automator_client.ensure_device_awake",
|
||||
return_value="host_heartbeat",
|
||||
)
|
||||
@patch("artemis.clients.ui_automator_client._ensure_maestro_not_installed")
|
||||
def test_client_disconnect_does_not_send_power_cleanup_commands(
|
||||
_mock_remove_maestro, mock_ensure_awake, _mock_connect
|
||||
):
|
||||
client = UIAutomatorClient("device-123")
|
||||
client.connect()
|
||||
|
||||
client._start_awake_heartbeat()
|
||||
client._stop_awake_heartbeat()
|
||||
client.disconnect()
|
||||
|
||||
assert mock_thread.call_args.kwargs["daemon"] is True
|
||||
thread.start.assert_called_once_with()
|
||||
stop_event.set.assert_called_once_with()
|
||||
thread.join.assert_called_once_with(timeout=2.0)
|
||||
mock_ensure_awake.assert_called_once_with("device-123")
|
||||
assert client._device is None
|
||||
assert client._awake_strategy is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -150,16 +74,3 @@ async def test_android_driver_disconnect_cleans_up_ui_client():
|
||||
await driver.disconnect()
|
||||
|
||||
ui_client.disconnect.assert_called_once_with()
|
||||
|
||||
|
||||
@patch("artemis.clients.ui_automator_client.subprocess.run")
|
||||
def test_keep_device_awake_continues_after_an_adb_failure(mock_run):
|
||||
mock_run.side_effect = [
|
||||
completed(returncode=1, stderr="not allowed"),
|
||||
OSError("adb temporarily unavailable"),
|
||||
completed(),
|
||||
completed("mStayOn=true\n"),
|
||||
]
|
||||
|
||||
assert _keep_device_awake("device-123") == AWAKE_STRATEGY_STAY_ON
|
||||
assert mock_run.call_count == 4
|
||||
@@ -8,7 +8,32 @@
|
||||
|
||||
import os
|
||||
|
||||
from artemis.interfaces.cli.commands.ui import _showcase_build_required
|
||||
from artemis.interfaces.cli.commands import ui
|
||||
from artemis.interfaces.cli.commands.ui import (
|
||||
_resolve_npm_executable,
|
||||
_showcase_build_required,
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_npm_executable_prefers_windows_command_shim(monkeypatch):
|
||||
paths = {
|
||||
"npm.cmd": r"C:\Program Files\nodejs\npm.cmd",
|
||||
"npm.exe": None,
|
||||
"npm": r"C:\Program Files\nodejs\npm.cmd",
|
||||
}
|
||||
monkeypatch.setattr(ui.shutil, "which", paths.get)
|
||||
|
||||
assert _resolve_npm_executable("win32") == paths["npm.cmd"]
|
||||
|
||||
|
||||
def test_resolve_npm_executable_uses_npm_on_posix(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
ui.shutil,
|
||||
"which",
|
||||
lambda command: "/usr/local/bin/npm" if command == "npm" else None,
|
||||
)
|
||||
|
||||
assert _resolve_npm_executable("linux") == "/usr/local/bin/npm"
|
||||
|
||||
|
||||
def test_showcase_build_required_when_source_is_newer(tmp_path):
|
||||
|
||||
Reference in new issue
Block a user