From 42b898866196fa573005b2bb28196c3d55960512 Mon Sep 17 00:00:00 2001 From: somew1nd Date: Mon, 24 Aug 2026 14:34:17 -0700 Subject: [PATCH] feat: add global device queue and readiness tracking --- apps/admin_console/core/state.py | 24 ++ .../repositories/session_repository.py | 40 +- apps/admin_console/routers/sessions.py | 3 +- apps/admin_console/routers/tasks.py | 49 ++- apps/admin_console/server.py | 11 +- apps/admin_console/services/ipc_service.py | 3 + .../services/task_queue_service.py | 234 ++++++++--- .../agent-stream/agent-stream-startup.spec.ts | 55 +++ .../agent-stream/agent-stream.component.html | 96 +++-- .../agent-stream/agent-stream.component.scss | 248 +++++------ .../agent-stream/agent-stream.component.ts | 138 +++++- .../chat-interface.component.ts | 17 +- .../src/app/core/models/system.model.ts | 1 + .../src/app/pages/home/home.component.html | 34 +- .../src/app/pages/home/home.component.scss | 27 +- .../pages/workspace/workspace.component.ts | 16 +- .../src/app/services/agent.service.spec.ts | 65 +++ .../src/app/services/agent.service.ts | 125 +++++- .../src/app/utils/tool-formatter.util.spec.ts | 13 +- .../src/app/utils/tool-formatter.util.ts | 6 +- artemis/agents/validator/validator.py | 90 +++- artemis/clients/ui_automator_client.py | 182 +------- artemis/core/diagnostics/engine.py | 4 + artemis/core/diagnostics/probes/adb_probe.py | 272 ++++++++++++ artemis/core/diagnostics/schema.py | 4 + artemis/data_engine/engine.py | 9 + artemis/interfaces/cli/commands/mcp.py | 51 ++- artemis/interfaces/cli/commands/run.py | 4 + artemis/interfaces/cli/commands/ui.py | 21 +- artemis/mcp/adb_server.py | 8 +- artemis/mcp/xml_search_server.py | 8 +- artemis/runtime/__init__.py | 10 + artemis/runtime/awake_lease.py | 288 +++++++++++++ artemis/runtime/awake_service.py | 300 +++++++++++++ artemis/runtime/device_lock.py | 395 ++++++++++++++++-- artemis/runtime/supervisor.py | 26 ++ artemis/sdk/agent.py | 107 ++++- artemis/services/llm.py | 20 +- artemis/utils/startup_progress.py | 58 +++ mcp_server/server.py | 13 +- mcp_server/tools/task_manager.py | 1 + mcp_server/tools/task_runner.py | 17 + .../test_session_repository_liveness.py | 39 ++ .../admin_console/test_startup_progress.py | 51 +++ .../test_task_device_readiness.py | 76 ++++ .../admin_console/test_task_queue_service.py | 151 ++++++- tests/unit/agents/test_validator.py | 53 +++ tests/unit/core/test_diagnostics.py | 79 ++++ tests/unit/mcp/test_mcp_tools.py | 32 ++ tests/unit/runtime/test_awake_lease.py | 128 ++++++ tests/unit/runtime/test_awake_service.py | 166 ++++++++ tests/unit/runtime/test_device_lock.py | 127 +++++- tests/unit/runtime/test_supervisor.py | 27 ++ .../unit/sdk/test_agent_trace_finalization.py | 85 +++- tests/unit/test_llm_pause.py | 31 ++ tests/unit/test_ui_automator_client_awake.py | 157 ++----- tests/unit/test_ui_command.py | 27 +- 57 files changed, 3606 insertions(+), 716 deletions(-) create mode 100644 apps/showcase_ui/src/app/components/agent-stream/agent-stream-startup.spec.ts create mode 100644 artemis/runtime/awake_lease.py create mode 100644 artemis/runtime/awake_service.py create mode 100644 artemis/utils/startup_progress.py create mode 100644 tests/unit/admin_console/test_session_repository_liveness.py create mode 100644 tests/unit/admin_console/test_startup_progress.py create mode 100644 tests/unit/admin_console/test_task_device_readiness.py create mode 100644 tests/unit/runtime/test_awake_lease.py create mode 100644 tests/unit/runtime/test_awake_service.py create mode 100644 tests/unit/runtime/test_supervisor.py diff --git a/apps/admin_console/core/state.py b/apps/admin_console/core/state.py index 96edea3..452b485 100644 --- a/apps/admin_console/core/state.py +++ b/apps/admin_console/core/state.py @@ -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"] diff --git a/apps/admin_console/database/repositories/session_repository.py b/apps/admin_console/database/repositories/session_repository.py index 0293e6d..bde52f5 100644 --- a/apps/admin_console/database/repositories/session_repository.py +++ b/apps/admin_console/database/repositories/session_repository.py @@ -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: diff --git a/apps/admin_console/routers/sessions.py b/apps/admin_console/routers/sessions.py index 64a8ee9..55b6c8c 100644 --- a/apps/admin_console/routers/sessions.py +++ b/apps/admin_console/routers/sessions.py @@ -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) diff --git a/apps/admin_console/routers/tasks.py b/apps/admin_console/routers/tasks.py index 774d616..ccc9071 100644 --- a/apps/admin_console/routers/tasks.py +++ b/apps/admin_console/routers/tasks.py @@ -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 diff --git a/apps/admin_console/server.py b/apps/admin_console/server.py index eaefcf5..464fada 100644 --- a/apps/admin_console/server.py +++ b/apps/admin_console/server.py @@ -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 diff --git a/apps/admin_console/services/ipc_service.py b/apps/admin_console/services/ipc_service.py index e1c3d08..f93accb 100644 --- a/apps/admin_console/services/ipc_service.py +++ b/apps/admin_console/services/ipc_service.py @@ -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) diff --git a/apps/admin_console/services/task_queue_service.py b/apps/admin_console/services/task_queue_service.py index e576aec..99f35f8 100644 --- a/apps/admin_console/services/task_queue_service.py +++ b/apps/admin_console/services/task_queue_service.py @@ -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: diff --git a/apps/showcase_ui/src/app/components/agent-stream/agent-stream-startup.spec.ts b/apps/showcase_ui/src/app/components/agent-stream/agent-stream-startup.spec.ts new file mode 100644 index 0000000..041e6d7 --- /dev/null +++ b/apps/showcase_ui/src/app/components/agent-stream/agent-stream-startup.spec.ts @@ -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(); + }); +}); diff --git a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html index c5354a9..0a818ab 100644 --- a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html +++ b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html @@ -290,40 +290,43 @@
- -
-
- pause_circle -
- Task paused - The AI request failed. Resume the task to try again. -
-
- -
- -
+
sensors

No Stream Activity Selected

Please select a session from the Task List on the right to view its real-time log payloads.

-
+
+
+
+ Worked for {{ startupPreparationDuration() }}s +
+
+
+
+
+
+
+ +
+ + {{ item.message }} · {{ item.isActive ? 'Waiting ' : 'Waited ' }}{{ item.elapsed }} + +
+
+
+
+
+
+
+
+
Worked for {{ phase.durationSeconds }}s @@ -558,11 +561,11 @@
-
+
+
error LLM Request Failed @@ -576,24 +579,17 @@ Failed - {{ isActionExpanded('llm-failure-' + block.id + '-' + itemIdx) ? 'expand_less' : 'expand_more' }}
- Reason: + What happened: + The AI request could not be completed after automatic retries. +
+
+ Failure reason: {{ getLLMErrorText(item.data) }}
@@ -611,6 +607,22 @@
+
+ +
+ + Task paused · no steps are running +
@@ -842,7 +854,7 @@
-
+
progress_activity {{ currentPlanningText() }} diff --git a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.scss b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.scss index ce11e13..1070fa0 100644 --- a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.scss +++ b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.scss @@ -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; diff --git a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.ts b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.ts index ae4be5d..deb9650 100644 --- a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.ts +++ b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.ts @@ -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(() => { - 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 { diff --git a/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.ts b/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.ts index 7c5b162..6fc29e0 100644 --- a/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.ts +++ b/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.ts @@ -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('/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(); }, diff --git a/apps/showcase_ui/src/app/core/models/system.model.ts b/apps/showcase_ui/src/app/core/models/system.model.ts index 1474ab3..abcd3ef 100644 --- a/apps/showcase_ui/src/app/core/models/system.model.ts +++ b/apps/showcase_ui/src/app/core/models/system.model.ts @@ -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[]; } diff --git a/apps/showcase_ui/src/app/pages/home/home.component.html b/apps/showcase_ui/src/app/pages/home/home.component.html index 79f3921..d83f680 100644 --- a/apps/showcase_ui/src/app/pages/home/home.component.html +++ b/apps/showcase_ui/src/app/pages/home/home.component.html @@ -937,7 +937,7 @@ } - @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') {
@@ -1357,6 +1357,27 @@
} + + @if (adbProbe()?.status === 'warn' && (adbProbe()?.summary === 'Device Locked' || adbProbe()?.summary === 'Lock State Unknown')) { +
+
+ lock +
+
+ {{ adbProbe()?.summary === 'Device Locked' ? 'Device Is Locked' : 'Unable to Verify Lock State' }} +
+
+ {{ adbProbe()?.description }} +
+
+ +
+
+ } + @if (adbProbe()?.status === 'warn' && adbProbe()?.summary === 'Device Unauthorized') {
@@ -1478,8 +1499,12 @@ @if (!isReady()) {
- warning - Prerequisites incomplete: runtime, credentials, or Android device is not ready. Click here to open Setup Guide. + {{ adbProbe()?.summary === 'Device Locked' ? 'lock' : 'warning' }} + + {{ 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.' }} + arrow_forward
} @@ -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()) { diff --git a/apps/showcase_ui/src/app/pages/home/home.component.scss b/apps/showcase_ui/src/app/pages/home/home.component.scss index 69ff4fb..a432149 100644 --- a/apps/showcase_ui/src/app/pages/home/home.component.scss +++ b/apps/showcase_ui/src/app/pages/home/home.component.scss @@ -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; + } } } } diff --git a/apps/showcase_ui/src/app/pages/workspace/workspace.component.ts b/apps/showcase_ui/src/app/pages/workspace/workspace.component.ts index e690ad7..ee973dc 100644 --- a/apps/showcase_ui/src/app/pages/workspace/workspace.component.ts +++ b/apps/showcase_ui/src/app/pages/workspace/workspace.component.ts @@ -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('/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) => { diff --git a/apps/showcase_ui/src/app/services/agent.service.spec.ts b/apps/showcase_ui/src/app/services/agent.service.spec.ts index a8dbc2d..02000e8 100644 --- a/apps/showcase_ui/src/app/services/agent.service.spec.ts +++ b/apps/showcase_ui/src/app/services/agent.service.spec.ts @@ -7,9 +7,38 @@ describe('AgentService live LLM retry timeline', () => { function createServiceWithoutPolling(): AgentService { const service = Object.create(AgentService.prototype) as AgentService; service.sessionLogs = signal([]); + service.startupProgressBySession = signal({}); + (service as any).pendingStartupProgress = signal([]); 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([{ session_id: 'session-1', status: 'paused' }]); + (service as any).pendingQueue = signal([]); + service.agentStatus = signal('paused'); + service.runningSessionId = signal('session-1'); + service.isPaused = signal(true); + service.pausedError = signal('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([{ session_id: 'session-1', status: 'paused' }]); + (service as any).pendingQueue = signal([]); + service.agentStatus = signal('paused'); + service.runningSessionId = signal('session-1'); + service.isPaused = signal(true); + service.pausedError = signal('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', () => { diff --git a/apps/showcase_ui/src/app/services/agent.service.ts b/apps/showcase_ui/src/app/services/agent.service.ts index e09cc8e..6e5d577 100644 --- a/apps/showcase_ui/src/app/services/agent.service.ts +++ b/apps/showcase_ui/src/app/services/agent.service.ts @@ -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(null); public activeModel = signal<{ name: string; id: string; provider: string } | null>(null); public userPinnedSessionId = signal(null); // Pinned session if user explicitly selected a non-running task + public startupProgressBySession = signal>({}); + private pendingStartupProgress = signal([]); + + 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>({}); @@ -210,6 +228,12 @@ export class AgentService { enableOutputter?: boolean ): Observable { 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(); diff --git a/apps/showcase_ui/src/app/utils/tool-formatter.util.spec.ts b/apps/showcase_ui/src/app/utils/tool-formatter.util.spec.ts index 502b565..17f23f6 100644 --- a/apps/showcase_ui/src/app/utils/tool-formatter.util.spec.ts +++ b/apps/showcase_ui/src/app/utils/tool-formatter.util.spec.ts @@ -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', () => { diff --git a/apps/showcase_ui/src/app/utils/tool-formatter.util.ts b/apps/showcase_ui/src/app/utils/tool-formatter.util.ts index d3a4dc9..11c9550 100644 --- a/apps/showcase_ui/src/app/utils/tool-formatter.util.ts +++ b/apps/showcase_ui/src/app/utils/tool-formatter.util.ts @@ -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'; } diff --git a/artemis/agents/validator/validator.py b/artemis/agents/validator/validator.py index 3970339..492800d 100644 --- a/artemis/agents/validator/validator.py +++ b/artemis/agents/validator/validator.py @@ -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 diff --git a/artemis/clients/ui_automator_client.py b/artemis/clients/ui_automator_client.py index f40ece3..05ae698 100644 --- a/artemis/clients/ui_automator_client.py +++ b/artemis/clients/ui_automator_client.py @@ -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") diff --git a/artemis/core/diagnostics/engine.py b/artemis/core/diagnostics/engine.py index 7a1c644..d4f87be 100644 --- a/artemis/core/diagnostics/engine.py +++ b/artemis/core/diagnostics/engine.py @@ -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() diff --git a/artemis/core/diagnostics/probes/adb_probe.py b/artemis/core/diagnostics/probes/adb_probe.py index 6ffb16f..b7a8bf7 100644 --- a/artemis/core/diagnostics/probes/adb_probe.py +++ b/artemis/core/diagnostics/probes/adb_probe.py @@ -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, diff --git a/artemis/core/diagnostics/schema.py b/artemis/core/diagnostics/schema.py index e3e1221..49a0b07 100644 --- a/artemis/core/diagnostics/schema.py +++ b/artemis/core/diagnostics/schema.py @@ -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" diff --git a/artemis/data_engine/engine.py b/artemis/data_engine/engine.py index 194edd2..5b648ec 100644 --- a/artemis/data_engine/engine.py +++ b/artemis/data_engine/engine.py @@ -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()) diff --git a/artemis/interfaces/cli/commands/mcp.py b/artemis/interfaces/cli/commands/mcp.py index d66c144..89872fb 100644 --- a/artemis/interfaces/cli/commands/mcp.py +++ b/artemis/interfaces/cli/commands/mcp.py @@ -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() diff --git a/artemis/interfaces/cli/commands/run.py b/artemis/interfaces/cli/commands/run.py index 184d7ce..2faf1d5 100644 --- a/artemis/interfaces/cli/commands/run.py +++ b/artemis/interfaces/cli/commands/run.py @@ -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: diff --git a/artemis/interfaces/cli/commands/ui.py b/artemis/interfaces/cli/commands/ui.py index f372955..3f0498c 100644 --- a/artemis/interfaces/cli/commands/ui.py +++ b/artemis/interfaces/cli/commands/ui.py @@ -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") diff --git a/artemis/mcp/adb_server.py b/artemis/mcp/adb_server.py index 465e14d..61eb78c 100644 --- a/artemis/mcp/adb_server.py +++ b/artemis/mcp/adb_server.py @@ -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() diff --git a/artemis/mcp/xml_search_server.py b/artemis/mcp/xml_search_server.py index 4373024..6816b7a 100644 --- a/artemis/mcp/xml_search_server.py +++ b/artemis/mcp/xml_search_server.py @@ -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() diff --git a/artemis/runtime/__init__.py b/artemis/runtime/__init__.py index 219dadf..542a743 100644 --- a/artemis/runtime/__init__.py +++ b/artemis/runtime/__init__.py @@ -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", ] diff --git a/artemis/runtime/awake_lease.py b/artemis/runtime/awake_lease.py new file mode 100644 index 0000000..d1e3180 --- /dev/null +++ b/artemis/runtime/awake_lease.py @@ -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 diff --git a/artemis/runtime/awake_service.py b/artemis/runtime/awake_service.py new file mode 100644 index 0000000..f91d765 --- /dev/null +++ b/artemis/runtime/awake_service.py @@ -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() diff --git a/artemis/runtime/device_lock.py b/artemis/runtime/device_lock.py index 20bbc15..dc44d0b 100644 --- a/artemis/runtime/device_lock.py +++ b/artemis/runtime/device_lock.py @@ -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 diff --git a/artemis/runtime/supervisor.py b/artemis/runtime/supervisor.py index 1f8060c..4075db9 100644 --- a/artemis/runtime/supervisor.py +++ b/artemis/runtime/supervisor.py @@ -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, diff --git a/artemis/sdk/agent.py b/artemis/sdk/agent.py index 2ac771b..4bc0575 100644 --- a/artemis/sdk/agent.py +++ b/artemis/sdk/agent.py @@ -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 diff --git a/artemis/services/llm.py b/artemis/services/llm.py index 0be9c1e..a46661a 100644 --- a/artemis/services/llm.py +++ b/artemis/services/llm.py @@ -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 diff --git a/artemis/utils/startup_progress.py b/artemis/utils/startup_progress.py new file mode 100644 index 0000000..6b2da13 --- /dev/null +++ b/artemis/utils/startup_progress.py @@ -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 diff --git a/mcp_server/server.py b/mcp_server/server.py index 67037f0..a6caeff 100644 --- a/mcp_server/server.py +++ b/mcp_server/server.py @@ -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__": diff --git a/mcp_server/tools/task_manager.py b/mcp_server/tools/task_manager.py index f1370c3..5312dbf 100644 --- a/mcp_server/tools/task_manager.py +++ b/mcp_server/tools/task_manager.py @@ -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 diff --git a/mcp_server/tools/task_runner.py b/mcp_server/tools/task_runner.py index 8da4883..a308c09 100644 --- a/mcp_server/tools/task_runner.py +++ b/mcp_server/tools/task_runner.py @@ -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}" ) diff --git a/tests/unit/admin_console/test_session_repository_liveness.py b/tests/unit/admin_console/test_session_repository_liveness.py new file mode 100644 index 0000000..5f5273a --- /dev/null +++ b/tests/unit/admin_console/test_session_repository_liveness.py @@ -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" diff --git a/tests/unit/admin_console/test_startup_progress.py b/tests/unit/admin_console/test_startup_progress.py new file mode 100644 index 0000000..0ae9a80 --- /dev/null +++ b/tests/unit/admin_console/test_startup_progress.py @@ -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" diff --git a/tests/unit/admin_console/test_task_device_readiness.py b/tests/unit/admin_console/test_task_device_readiness.py new file mode 100644 index 0000000..46fc1f4 --- /dev/null +++ b/tests/unit/admin_console/test_task_device_readiness.py @@ -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() diff --git a/tests/unit/admin_console/test_task_queue_service.py b/tests/unit/admin_console/test_task_queue_service.py index ad974d3..0b3b145 100644 --- a/tests/unit/admin_console/test_task_queue_service.py +++ b/tests/unit/admin_console/test_task_queue_service.py @@ -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 = [] diff --git a/tests/unit/agents/test_validator.py b/tests/unit/agents/test_validator.py index ebf6ef5..636b85f 100644 --- a/tests/unit/agents/test_validator.py +++ b/tests/unit/agents/test_validator.py @@ -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 diff --git a/tests/unit/core/test_diagnostics.py b/tests/unit/core/test_diagnostics.py index 0a9391c..9222715 100644 --- a/tests/unit/core/test_diagnostics.py +++ b/tests/unit/core/test_diagnostics.py @@ -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.""" diff --git a/tests/unit/mcp/test_mcp_tools.py b/tests/unit/mcp/test_mcp_tools.py index acaf3fd..10a34d1 100644 --- a/tests/unit/mcp/test_mcp_tools.py +++ b/tests/unit/mcp/test_mcp_tools.py @@ -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 { diff --git a/tests/unit/runtime/test_awake_lease.py b/tests/unit/runtime/test_awake_lease.py new file mode 100644 index 0000000..97384c5 --- /dev/null +++ b/tests/unit/runtime/test_awake_lease.py @@ -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 diff --git a/tests/unit/runtime/test_awake_service.py b/tests/unit/runtime/test_awake_service.py new file mode 100644 index 0000000..311ba79 --- /dev/null +++ b/tests/unit/runtime/test_awake_service.py @@ -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"] diff --git a/tests/unit/runtime/test_device_lock.py b/tests/unit/runtime/test_device_lock.py index 9786ebd..9524a93 100644 --- a/tests/unit/runtime/test_device_lock.py +++ b/tests/unit/runtime/test_device_lock.py @@ -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() diff --git a/tests/unit/runtime/test_supervisor.py b/tests/unit/runtime/test_supervisor.py new file mode 100644 index 0000000..438157f --- /dev/null +++ b/tests/unit/runtime/test_supervisor.py @@ -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) diff --git a/tests/unit/sdk/test_agent_trace_finalization.py b/tests/unit/sdk/test_agent_trace_finalization.py index 9a051d5..a33cf00 100644 --- a/tests/unit/sdk/test_agent_trace_finalization.py +++ b/tests/unit/sdk/test_agent_trace_finalization.py @@ -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() diff --git a/tests/unit/test_llm_pause.py b/tests/unit/test_llm_pause.py index bc4f288..2ccd41f 100644 --- a/tests/unit/test_llm_pause.py +++ b/tests/unit/test_llm_pause.py @@ -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" diff --git a/tests/unit/test_ui_automator_client_awake.py b/tests/unit/test_ui_automator_client_awake.py index fd9ed57..12e3a82 100644 --- a/tests/unit/test_ui_automator_client_awake.py +++ b/tests/unit/test_ui_automator_client_awake.py @@ -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 diff --git a/tests/unit/test_ui_command.py b/tests/unit/test_ui_command.py index 1079574..c63033c 100644 --- a/tests/unit/test_ui_command.py +++ b/tests/unit/test_ui_command.py @@ -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):