feat: add global device queue and readiness tracking

This commit is contained in:
somew1nd committed 2026-08-24 14:34:17 -07:00
1 parent a62ac4aef1
commit 42b8988661
57 files changed
+3606 -716

No files matched your search

+24
View File
@@ -39,6 +39,7 @@ class ServerState:
self.active_session_id: str | None = None
self.was_stopped_manually: bool = False
self.cancelled_session_ids: set[str] = set()
self.startup_progress: dict[str, list[dict[str, Any]]] = {}
# Unified single source of truth for task queue
self.queue_items: list[dict[str, Any]] = []
@@ -171,6 +172,29 @@ class ServerState:
if callback in self.ipc_subscribers:
self.ipc_subscribers.remove(callback)
def record_startup_progress(self, data: dict[str, Any]) -> None:
"""Retain the short pre-trace timeline so late SSE clients can catch up."""
session_id = data.get("session_id")
stage = data.get("stage")
if not session_id or not stage:
return
key = str(session_id)
events = self.startup_progress.setdefault(key, [])
replacement_index = next(
(index for index, item in enumerate(events) if item.get("stage") == stage),
None,
)
snapshot = dict(data)
if replacement_index is None:
events.append(snapshot)
else:
events[replacement_index] = snapshot
self.startup_progress[key] = events[-16:]
def get_startup_progress(self, session_id: str) -> list[dict[str, Any]]:
return [dict(item) for item in self.startup_progress.get(str(session_id), [])]
def clear_queue(self):
"""Clears all pending items from the task queue."""
self.queue_items = [t for t in self.queue_items if t.get("status") == "running"]
@@ -27,6 +27,17 @@ class SessionRepository:
def __init__(self, db_path=None):
self.db_path = db_path
@staticmethod
def process_is_alive(pid: Any) -> bool:
"""Return whether a session's worker PID is still alive."""
try:
import psutil
process = psutil.Process(int(pid))
return process.is_running() and process.status() != psutil.STATUS_ZOMBIE
except Exception:
return False
def get_all_sessions(self) -> list[dict[str, Any]]:
with db_session(self.db_path) as conn:
cursor = conn.cursor()
@@ -135,24 +146,41 @@ class SessionRepository:
with db_session(self.db_path) as conn:
cursor = conn.cursor()
now = time.time()
count = 0
for s_id in orphaned_ids:
row = cursor.execute(
"SELECT pid FROM sessions WHERE session_id = ? AND status = ?",
(s_id, "running"),
).fetchone()
if row is None or self.process_is_alive(row["pid"]):
continue
cursor.execute(
"UPDATE sessions SET status = ?, end_time = ? "
"WHERE session_id = ? AND status = ?",
("failed", now, s_id, "running"),
)
count += cursor.rowcount
conn.commit()
return len(orphaned_ids)
return count
def cleanup_orphans_on_startup(self) -> int:
try:
with db_session(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute(
"UPDATE sessions SET status = ?, end_time = ? WHERE status = ?",
("failed", time.time(), "running"),
)
count = cursor.rowcount
rows = cursor.execute(
"SELECT session_id, pid FROM sessions WHERE status = ?", ("running",)
).fetchall()
count = 0
now = time.time()
for row in rows:
if self.process_is_alive(row["pid"]):
continue
cursor.execute(
"UPDATE sessions SET status = ?, end_time = ? "
"WHERE session_id = ? AND status = ?",
("failed", now, row["session_id"], "running"),
)
count += cursor.rowcount
conn.commit()
return count
except Exception:
+2 -1
View File
@@ -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)
+43 -6
View File
@@ -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
+10 -1
View File
@@ -47,7 +47,11 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, HTMLResponse
import uvicorn
from artemis.runtime import DeviceExecutionLock
from artemis.runtime import (
DeviceExecutionLock,
shutdown_awake_service,
start_awake_service,
)
try:
from admin_console.core.config import (
@@ -104,6 +108,7 @@ async def on_startup():
"""Startup lifecycle hooks."""
state.is_shutting_down = False
state.shutdown_event.clear()
await asyncio.to_thread(start_awake_service)
cleaned_device_locks = DeviceExecutionLock.cleanup_stale_locks()
if cleaned_device_locks:
print(f"[ServerStartup] Removed {cleaned_device_locks} stale device lock(s).")
@@ -143,6 +148,9 @@ async def on_shutdown():
if state.current_process is not None and state.current_process.returncode is None:
await task_queue_service._terminate_worker_process(state.current_process)
for item in state.queue_items:
if isinstance(item, dict):
DeviceExecutionLock.cancel_reservation(item.get("queue_ticket"))
DeviceExecutionLock.cleanup_stale_locks()
state.current_process = None
state.queue_items.clear()
@@ -151,6 +159,7 @@ async def on_shutdown():
await ipc_service.stop_server()
state.ipc_subscribers.clear()
await asyncio.to_thread(shutdown_awake_service)
# Mount modular routers
@@ -183,6 +183,9 @@ class IPCService:
state.current_goal = None
state.current_profile = None
if event_type == "startup_progress" and isinstance(data, dict):
state.record_startup_progress(data)
for cb in list(state.ipc_subscribers):
try:
cb(event_type, data)
+173 -61
View File
@@ -32,7 +32,7 @@ try:
)
from admin_console.core.state import state
from admin_console.database.repositories.session_repository import session_repo
from artemis.runtime import process_supervisor
from artemis.runtime import DeviceExecutionLock, process_supervisor
except ImportError:
from apps.admin_console.core.config import (
PAUSE_FILE,
@@ -42,7 +42,7 @@ except ImportError:
)
from apps.admin_console.core.state import state
from apps.admin_console.database.repositories.session_repository import session_repo
from artemis.runtime import process_supervisor
from artemis.runtime import DeviceExecutionLock, process_supervisor
class TaskQueueService:
@@ -59,6 +59,21 @@ class TaskQueueService:
except Exception:
pass
@classmethod
def _broadcast_startup_progress(
cls, session_id: str | None, stage: str, message: str
) -> None:
if not session_id:
return
data = {
"session_id": str(session_id),
"stage": stage,
"message": message,
"timestamp": time.time(),
}
state.record_startup_progress(data)
cls._broadcast_event("startup_progress", data)
@classmethod
def _get_next_pending_task(cls) -> dict[str, Any] | None:
"""Finds and returns the first pending task from queue_items."""
@@ -72,6 +87,13 @@ class TaskQueueService:
"""Removes a task from queue_items by session_id."""
if not session_id:
return
removed_items = [
t
for t in state.queue_items
if isinstance(t, dict) and t.get("session_id") == session_id
]
for item in removed_items:
DeviceExecutionLock.cancel_reservation(item.get("queue_ticket"))
state.queue_items = [
t
for t in state.queue_items
@@ -232,6 +254,10 @@ class TaskQueueService:
state.active_session_id = sess_id
state.was_stopped_manually = False
cls._broadcast_startup_progress(
sess_id, "launching", "Starting the execution process"
)
# Broadcast session_started so all connected clients know the task has started
cls._broadcast_event(
"session_started",
@@ -257,6 +283,10 @@ class TaskQueueService:
env["ARTEMIS_IPC_PORT"] = str(state.ipc_port)
if sess_id:
env["ARTEMIS_SESSION_ID"] = str(sess_id)
env["ARTEMIS_TASK_INGRESS"] = "frontend"
queue_ticket = task_item.get("queue_ticket")
if queue_ticket:
env[DeviceExecutionLock.QUEUE_TICKET_ENV] = str(queue_ticket)
cmd = [
sys.executable,
@@ -288,6 +318,16 @@ class TaskQueueService:
)
state.current_process = proc
task_item["pid"] = proc.pid
cls._broadcast_startup_progress(
sess_id, "process_ready", "Execution process started"
)
DeviceExecutionLock.transfer_reservation(
str(queue_ticket),
proc.pid,
description=f"frontend task: {goal[:120]}",
session_id=str(sess_id) if sess_id else None,
ingress="frontend",
)
if sys.platform == "win32" and isinstance(proc.stdout, asyncio.StreamReader):
output_task = asyncio.create_task(cls._forward_worker_output(proc.stdout))
@@ -373,6 +413,11 @@ class TaskQueueService:
now = time.time()
for i, goal in enumerate(goals):
sess_id = str(uuid.uuid4())
queue_ticket = DeviceExecutionLock.reserve(
description=f"frontend task: {goal[:120]}",
session_id=sess_id,
ingress="frontend",
)
task_item = {
"session_id": sess_id,
"goal": goal,
@@ -382,11 +427,15 @@ class TaskQueueService:
"locked_app_package": locked_app_package,
"app_path": app_path,
"status": "pending",
"queue_ticket": queue_ticket,
"created_at": now + i * 0.001,
"start_time": now + i * 0.001,
}
state.queue_items.append(task_item)
enqueued_tasks.append(task_item)
cls._broadcast_startup_progress(
sess_id, "queued", "Task received and queued"
)
# Wake worker immediately
state.wake_event.set()
@@ -400,64 +449,119 @@ class TaskQueueService:
@classmethod
def stop_tasks(cls, clear_all: bool = False) -> bool:
"""Stops the currently executing task and optionally clears pending tasks."""
"""Stop the one task currently controlling the global Artemis device.
The active lease is shared by frontend, MCP, CLI, SDK, and other UI
processes. Pending tasks are intentionally preserved unless the legacy
``clear_all`` option is explicitly requested, in which case only this
frontend server's pending submissions are cleared.
"""
stopped = False
stopped_session_id = state.active_session_id
owner = DeviceExecutionLock.get_active_owner()
owner_record_exists = DeviceExecutionLock.has_owner_record()
owner_pid = owner.pid if owner else None
local_pid = getattr(state.current_process, "pid", None)
is_local_owner = bool(owner_pid and local_pid and owner_pid == local_pid)
if not stopped_session_id:
running_item = next(
(
t
for t in state.queue_items
if isinstance(t, dict) and t.get("status") == "running"
),
None,
)
if running_item:
stopped_session_id = running_item.get("session_id")
elif state.queue_items and not clear_all:
stopped_session_id = state.queue_items[0].get("session_id")
if stopped_session_id:
getattr(state, "cancelled_session_ids", set()).add(str(stopped_session_id))
for t in state.queue_items:
if isinstance(t, dict) and t.get("status") == "running" and t.get("session_id"):
getattr(state, "cancelled_session_ids", set()).add(str(t["session_id"]))
running_item = next(
(
item
for item in state.queue_items
if isinstance(item, dict) and item.get("status") == "running"
),
None,
)
local_item = running_item or next(
(
item
for item in state.queue_items
if isinstance(item, dict) and item.get("status") == "pending"
),
None,
)
stopped_session_id = (
owner.session_id
if owner and owner.session_id
else state.active_session_id
if owner
else local_item.get("session_id")
if local_item
else None
)
if clear_all:
for item in state.queue_items:
if isinstance(item, dict) and item.get("status") != "running":
DeviceExecutionLock.cancel_reservation(item.get("queue_ticket"))
state.clear_queue()
stopped = True
state.was_stopped_manually = True
if state.current_process:
pid = getattr(state.current_process, "pid", None)
if pid:
if owner and DeviceExecutionLock.is_active_owner(owner):
stopped = process_supervisor.terminate_tree_verified(
owner.pid,
owner.process_created_at,
)
DeviceExecutionLock.cleanup_stale_locks()
elif owner is None and owner_record_exists:
# Never fall back to a frontend PID while another process has an
# owner record that is still being published or cannot be parsed.
return False
elif owner is None and state.current_process:
# The frontend worker can be stopped during its short initialization
# window before Agent acquires the global device lease.
state.was_stopped_manually = True
if local_pid:
try:
process_supervisor.terminate_tree(pid)
stopped = process_supervisor.terminate_tree(local_pid)
except Exception:
pass
stopped = False
try:
state.current_process.kill()
stopped = True
except Exception:
pass
# A dead worker is already stopped; cleanup must still be treated
# as a successful, idempotent stop operation.
stopped = True
is_local_owner = True
elif owner is None and local_item:
# Cancel a frontend submission before its worker has started. This
# does not touch pending reservations created by other ingresses.
DeviceExecutionLock.cancel_reservation(local_item.get("queue_ticket"))
stopped = True
is_local_owner = True
for _, conn_info in list(state.active_connections.items()):
pid = conn_info.get("pid")
if pid:
try:
process_supervisor.terminate_tree(pid)
stopped = True
except Exception as e:
print(f"Failed to kill external process {pid}: {e}")
if not stopped:
if clear_all:
state.wake_event.set()
return clear_all
# Always reset process and active states cleanly
state.current_process = None
state.active_connections.clear()
session_repo.mark_all_running_cancelled()
if is_local_owner:
state.was_stopped_manually = True
state.current_process = None
if stopped_session_id:
state.cancelled_session_ids.add(str(stopped_session_id))
for session_id, conn_info in list(state.active_connections.items()):
if (stopped_session_id and str(session_id) == str(stopped_session_id)) or (
owner_pid and conn_info.get("pid") == owner_pid
):
state.active_connections.pop(session_id, None)
# Broadcast session_ended event so all SSE streams and subscribers update immediately
if stopped_session_id:
session_repo.update_session_status(
str(stopped_session_id), "cancelled", time.time()
)
if owner and owner.ingress == "mcp":
try:
from mcp_server.utils import trace_store
trace_store.update_trace_status(
str(stopped_session_id),
"cancelled",
error="Task stopped from the Artemis frontend.",
)
except Exception as exc:
print(f"Failed to update MCP cancellation status: {exc}")
cls._broadcast_event(
"session_ended",
{
@@ -466,27 +570,37 @@ class TaskQueueService:
"was_stopped_manually": True,
},
)
stopped = True
state.active_session_id = None
state.current_goal = None
state.current_profile = None
if state.active_session_id and (
not stopped_session_id
or str(state.active_session_id) == str(stopped_session_id)
):
state.active_session_id = None
state.current_goal = None
state.current_profile = None
# Ensure any running or stopped task is removed from queue_items so worker_loop is unblocked
if clear_all:
state.queue_items = []
else:
if is_local_owner and stopped_session_id:
stopped_item = next(
(
item
for item in state.queue_items
if isinstance(item, dict)
and str(item.get("session_id")) == str(stopped_session_id)
),
None,
)
if stopped_item:
DeviceExecutionLock.cancel_reservation(stopped_item.get("queue_ticket"))
state.queue_items = [
t
for t in state.queue_items
item
for item in state.queue_items
if not (
isinstance(t, dict)
and (
t.get("status") == "running"
or (stopped_session_id and t.get("session_id") == stopped_session_id)
)
isinstance(item, dict)
and str(item.get("session_id")) == str(stopped_session_id)
)
]
if clear_all:
state.queue_items = []
if PAUSE_FILE.exists():
try:
@@ -494,11 +608,9 @@ class TaskQueueService:
except Exception:
pass
# Wake up worker to process the next pending task or transition cleanly
cls.ensure_worker_running()
state.wake_event.set()
return stopped or True
return True
@classmethod
def resume_task(cls) -> bool:
@@ -0,0 +1,55 @@
import { StartupProgressEvent } from '../../services/agent.service';
import { buildStartupWorkItems } from './agent-stream.component';
describe('startup Work block', () => {
it('shows only the three device preparation operations', () => {
const events: StartupProgressEvent[] = [
{ stage: 'submitting', message: 'Submitting the task', timestamp: 100 },
{ stage: 'queued', message: 'Task received and queued', timestamp: 100.9 },
{ stage: 'launching', message: 'Starting the execution process', timestamp: 101 },
{ stage: 'configuration', message: 'Loading the run configuration', timestamp: 103.1 },
{ stage: 'device_check', message: 'Checking the Android device', timestamp: 103.2 },
{ stage: 'device_ready', message: 'Android device connected', timestamp: 123 },
{ stage: 'model_warmup', message: 'Warming the model connection', timestamp: 123 },
{ stage: 'uiautomator', message: 'Connecting to UI Automator', timestamp: 124 },
{ stage: 'uiautomator_ready', message: 'UI Automator is ready', timestamp: 128 },
{ stage: 'environment', message: 'Preparing the device environment', timestamp: 128 },
{ stage: 'environment_ready', message: 'Device environment is ready', timestamp: 131 }
];
const items = buildStartupWorkItems(events, 131, false, true);
expect(items.map((item) => item.stage)).toEqual([
'device_ready',
'uiautomator_ready',
'environment_ready'
]);
expect(items.map((item) => item.elapsed)).toEqual(['20s', '4.0s', '3.0s']);
expect(items.every((item) => !item.isActive)).toBeTrue();
});
it('keeps the current preparation operation live without exposing first-action text', () => {
const events: StartupProgressEvent[] = [
{ stage: 'device_check', message: 'Checking the Android device', timestamp: 10 }
];
const [item] = buildStartupWorkItems(events, 11.4, false, true);
expect(item.message).toBe('Checking the Android device');
expect(item.elapsed).toBe('1.4s');
expect(item.isActive).toBeTrue();
});
it('uses old first_response events only as a hidden completion boundary', () => {
const events: StartupProgressEvent[] = [
{ stage: 'environment', message: 'Preparing the device environment', timestamp: 20 },
{ stage: 'first_response', message: 'Generating the first action', timestamp: 23 }
];
const [item] = buildStartupWorkItems(events, 24, false, true);
expect(item.message).toBe('Device environment is ready');
expect(item.elapsed).toBe('3.0s');
expect(item.isActive).toBeFalse();
});
});
@@ -290,40 +290,43 @@
<!-- Event Logs Section -->
<main class="stream-logs-content" (scroll)="onStreamScroll($event)">
<!-- Keep recovery available even when a persisted failure trace is absent. -->
<div class="paused-task-banner" *ngIf="isViewingPausedTask()" role="status">
<div class="paused-task-message">
<span class="material-symbols-outlined paused-task-icon">pause_circle</span>
<div>
<strong>Task paused</strong>
<span>The AI request failed. Resume the task to try again.</span>
</div>
</div>
<button
type="button"
class="btn-resume-task"
(click)="resumePausedTask($event)"
title="Resume the paused task"
aria-label="Resume the paused task"
>
<span class="material-symbols-outlined">play_arrow</span>
Resume
</button>
</div>
<div *ngIf="consolidatedBlocks().length === 0 && !showPlanningLoader()" class="empty-state">
<div *ngIf="consolidatedBlocks().length === 0 && !showPlanningLoader() && startupWorkItems().length === 0" class="empty-state">
<div class="empty-icon"><span class="material-symbols-outlined">sensors</span></div>
<h3>No Stream Activity Selected</h3>
<p>Please select a session from the Task List on the right to view its real-time log payloads.</p>
</div>
<!-- The phase-based session logs list -->
<div class="logs-list-container" *ngIf="phases().length > 0 || showPlanningLoader()">
<div class="logs-list-container" *ngIf="phases().length > 0 || showPlanningLoader() || startupWorkItems().length > 0">
<div class="phase-container startup-phase" *ngIf="startupWorkItems().length > 0" aria-live="polite">
<div class="phase-header first-phase">
<span class="phase-worked-time">Worked for {{ startupPreparationDuration() }}s</span>
</div>
<div class="phase-body" aria-label="Task startup progress">
<div class="block-item type-stream">
<div class="step-body">
<div class="step-actions-container">
<div class="llm-retry-notes">
<div class="llm-retry-note" *ngFor="let item of startupWorkItems()">
<span class="stream-status-dot" [class.streaming]="item.isActive"></span>
<div class="llm-retry-note-text">
<span class="llm-retry-status-line">
{{ item.message }} · {{ item.isActive ? 'Waiting ' : 'Waited ' }}{{ item.elapsed }}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="phase-container" *ngFor="let phase of phases(); let first = first; trackBy: trackPhase">
<div
class="phase-header"
*ngIf="hasVisibleBlocks(phase)"
[class.first-phase]="first"
[class.first-phase]="first && startupWorkItems().length === 0"
>
<span class="phase-worked-time">Worked for {{ phase.durationSeconds }}s</span>
<span class="phase-token-usage" *ngIf="phase.tokens" [title]="getTokenTooltip(phase)">
@@ -558,11 +561,11 @@
</div>
<!-- Failed LLM Calls (Permanently maintain Action Card format in red) -->
<div class="step-android-action-card tool-action-card is-failed"
[class.has-details]="hasLLMFailureRetryEntries(item.data)"
[class.is-expanded]="isActionExpanded('llm-failure-' + block.id + '-' + itemIdx)"
(click)="hasLLMFailureRetryEntries(item.data) && toggleActionExpanded('llm-failure-' + block.id + '-' + itemIdx, $event)"
*ngIf="isDisplayableLLMFailure(item.data)">
<div class="llm-failure-stack" *ngIf="isDisplayableLLMFailure(item.data)">
<div class="step-android-action-card tool-action-card is-failed"
[class.has-details]="hasLLMFailureRetryEntries(item.data)"
[class.is-expanded]="isActionExpanded('llm-failure-' + block.id + '-' + itemIdx)"
(click)="hasLLMFailureRetryEntries(item.data) && toggleActionExpanded('llm-failure-' + block.id + '-' + itemIdx, $event)">
<div class="action-card-header llm-failure-header">
<span class="material-symbols-outlined action-icon is-failed">error</span>
<span class="action-title">LLM Request Failed</span>
@@ -576,24 +579,17 @@
Failed
</span>
</ng-template>
<button
type="button"
class="btn-resume-llm"
*ngIf="isTerminalLLMFailure(item.data)"
(click)="resumePausedTask($event)"
title="Resume the paused task"
aria-label="Resume the paused task"
>
<span class="material-symbols-outlined">play_arrow</span>
Resume
</button>
<span class="action-expand-icon material-symbols-outlined" *ngIf="hasLLMFailureRetryEntries(item.data)">
{{ isActionExpanded('llm-failure-' + block.id + '-' + itemIdx) ? 'expand_less' : 'expand_more' }}
</span>
</div>
<div class="action-card-body">
<div class="action-detail-row">
<span class="detail-label">Reason:</span>
<span class="detail-label">What happened:</span>
<span class="detail-value">The AI request could not be completed after automatic retries.</span>
</div>
<div class="action-detail-row">
<span class="detail-label">Failure reason:</span>
<span class="detail-value">{{ getLLMErrorText(item.data) }}</span>
</div>
<div class="action-detail-row" *ngIf="getLLMFailureWaited(item.data) as waited">
@@ -611,6 +607,22 @@
</div>
</div>
</div>
</div>
<div class="llm-resume-row" *ngIf="isTerminalLLMFailure(item.data)">
<button
type="button"
class="btn-continue-task"
(click)="resumePausedTask($event)"
title="Continue the paused task"
aria-label="Continue the paused task"
>
<span class="material-symbols-outlined continue-icon">play_arrow</span>
<span>Continue task</span>
<span class="material-symbols-outlined continue-arrow">arrow_forward</span>
</button>
<span class="llm-resume-hint">Task paused · no steps are running</span>
</div>
</div>
<!-- Generic Tool Calls (Text format for note tools, video analyzer, and other sub-agents) -->
@@ -842,7 +854,7 @@
</div>
<!-- Planning Loader -->
<div class="planning-loader-container" *ngIf="showPlanningLoader()">
<div class="planning-loader-container" *ngIf="showPlanningLoader() && (startupWorkItems().length === 0 || startupPreparationIsComplete() || consolidatedBlocks().length > 0)">
<div class="planning-loader">
<span class="material-symbols-outlined spin-animation">progress_activity</span>
<span class="loader-text">{{ currentPlanningText() }}</span>
@@ -1060,99 +1060,6 @@ $uniform-gap: 8px;
}
}
.paused-task-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 14px;
margin-bottom: 12px;
background: #fff7ed;
border: 1px solid #fdba74;
border-radius: 10px;
color: #9a3412;
box-shadow: 0 1px 3px rgba(154, 52, 18, 0.08);
.paused-task-message {
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
.paused-task-icon {
flex: 0 0 auto;
font-size: 21px;
color: #ea580c;
}
div {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
strong {
font-size: 12px;
line-height: 1.2;
}
span:not(.paused-task-icon) {
font-size: 11px;
line-height: 1.35;
color: #c2410c;
}
}
.btn-resume-task {
flex: 0 0 auto;
min-height: 32px;
padding: 6px 12px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
border: 1px solid #2563eb;
border-radius: 8px;
background: #2563eb;
color: #ffffff;
font-family: inherit;
font-size: 12px;
font-weight: 700;
line-height: 1;
cursor: pointer;
box-shadow: 0 2px 5px rgba(37, 99, 235, 0.2);
transition: background 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
.material-symbols-outlined {
font-size: 17px;
}
&:hover {
background: #1d4ed8;
box-shadow: 0 3px 8px rgba(37, 99, 235, 0.28);
}
&:active {
transform: scale(0.97);
}
&:focus-visible {
outline: 2px solid rgba(37, 99, 235, 0.35);
outline-offset: 2px;
}
}
@media (max-width: 560px) {
align-items: stretch;
flex-direction: column;
.btn-resume-task {
width: 100%;
}
}
}
.logs-list-container {
display: flex;
flex-direction: column;
@@ -1203,8 +1110,11 @@ $uniform-gap: 8px;
.phase-header {
font-size: 12px;
font-weight: normal;
line-height: 1.5;
min-height: 20px;
box-sizing: border-box;
color: #71717a;
margin-top: 14px;
margin-top: $uniform-gap * 2;
margin-bottom: 8px;
user-select: none;
display: flex;
@@ -2230,6 +2140,111 @@ $uniform-gap: 8px;
}
}
.llm-failure-stack {
width: 380px;
max-width: 100%;
margin-left: -19px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 10px;
.step-android-action-card {
width: 100%;
margin-left: 0;
box-sizing: border-box;
}
.llm-resume-row {
min-height: 36px;
display: flex;
align-items: center;
gap: 10px;
}
.btn-continue-task {
min-height: 36px;
padding: 7px 12px 7px 10px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
box-sizing: border-box;
border: 1px solid #1d4ed8;
border-radius: 9px;
background: #2563eb;
color: #ffffff;
font-family: inherit;
font-size: 12px;
font-weight: 700;
line-height: 1;
cursor: pointer;
box-shadow: 0 2px 7px rgba(37, 99, 235, 0.22);
transition: background 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
.continue-icon {
width: 22px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
margin: -2px 0 -2px -2px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.16);
font-size: 16px;
}
.continue-arrow {
font-size: 15px;
opacity: 0.72;
transition: transform 0.18s ease, opacity 0.18s ease;
}
&:hover {
background: #1d4ed8;
border-color: #1e40af;
box-shadow: 0 4px 11px rgba(37, 99, 235, 0.28);
transform: translateY(-1px);
.continue-arrow {
opacity: 1;
transform: translateX(2px);
}
}
&:active {
box-shadow: 0 1px 4px rgba(37, 99, 235, 0.2);
transform: translateY(0) scale(0.98);
}
&:focus-visible {
outline: 3px solid rgba(59, 130, 246, 0.25);
outline-offset: 2px;
}
}
.llm-resume-hint {
color: #94a3b8;
font-size: 11px;
line-height: 1.35;
white-space: nowrap;
}
@media (max-width: 560px) {
.llm-resume-row {
width: 100%;
}
.btn-continue-task {
flex: 1;
}
.llm-resume-hint {
display: none;
}
}
}
.step-android-action-card {
background: #f8fafc;
border: 1px solid #e2e8f0;
@@ -2488,51 +2503,6 @@ $uniform-gap: 8px;
}
&.llm-failure-header {
flex-wrap: wrap;
.btn-resume-llm {
flex: 0 0 auto;
min-height: 26px;
padding: 3px 9px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
box-sizing: border-box;
border: 1px solid #bfdbfe;
border-radius: 7px;
background: #ffffff;
color: #2563eb;
font-family: inherit;
font-size: 11px;
font-weight: 700;
line-height: 1;
cursor: pointer;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
transition: background 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
.material-symbols-outlined {
font-size: 15px;
}
&:hover {
background: #eff6ff;
border-color: #93c5fd;
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.12);
}
&:active {
transform: scale(0.97);
}
&:focus-visible {
outline: 2px solid rgba(37, 99, 235, 0.35);
outline-offset: 2px;
}
}
}
.action-expand-icon {
font-size: 18px;
color: #94a3b8;
@@ -19,7 +19,7 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { HttpClient } from '@angular/common/http';
import { AgentService } from '../../services/agent.service';
import { AgentService, StartupProgressEvent } from '../../services/agent.service';
import { Session } from '../../core/models/session.model';
import { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote } from '../../core/models/markdown.model';
import { StepBlock, PhaseBlock, StepEvent, ActionParam, CheckerResult } from '../../core/models/stream.model';
@@ -43,6 +43,83 @@ export const PLANNING_LOADER_PHRASES: string[] = [
'Strategizing tactical moves...'
];
export interface StartupWorkItem extends StartupProgressEvent {
isActive: boolean;
elapsed: string;
}
interface StartupWorkStage {
started: string;
completed: string;
completedMessage: string;
}
const STARTUP_WORK_STAGES: StartupWorkStage[] = [
{
started: 'device_check',
completed: 'device_ready',
completedMessage: 'Android device connected'
},
{
started: 'uiautomator',
completed: 'uiautomator_ready',
completedMessage: 'UI Automator is ready'
},
{
started: 'environment',
completed: 'environment_ready',
completedMessage: 'Device environment is ready'
}
];
function formatStartupElapsed(seconds: number): string {
const safeSeconds = Math.max(0, seconds);
return safeSeconds < 10
? `${safeSeconds.toFixed(1)}s`
: `${Math.round(safeSeconds)}s`;
}
/**
* Collapse noisy process-level startup events into the three device preparation
* operations that are useful to someone watching a run.
*/
export function buildStartupWorkItems(
events: StartupProgressEvent[],
nowSeconds: number,
executionHasOutput: boolean,
agentIsActive: boolean
): StartupWorkItem[] {
const byStage = new Map(events.map((event) => [event.stage, event]));
const firstResponse = byStage.get('first_response');
return STARTUP_WORK_STAGES.flatMap((stage, stageIndex) => {
const started = byStage.get(stage.started);
const explicitlyCompleted = byStage.get(stage.completed);
if (!started && !explicitlyCompleted) return [];
const nextStageStarted = STARTUP_WORK_STAGES
.slice(stageIndex + 1)
.map((nextStage) => byStage.get(nextStage.started) || byStage.get(nextStage.completed))
.find((event): event is StartupProgressEvent => Boolean(event));
const inferredCompletion = stage.started === 'environment'
? firstResponse
: nextStageStarted;
const completed = explicitlyCompleted || inferredCompletion;
const isActive = !completed && !executionHasOutput && agentIsActive;
const startTimestamp = started?.timestamp || explicitlyCompleted?.timestamp || nowSeconds;
const endTimestamp = completed?.timestamp
|| (isActive ? nowSeconds : events[events.length - 1]?.timestamp || startTimestamp);
return [{
...(explicitlyCompleted || started!),
message: explicitlyCompleted?.message
|| (completed ? stage.completedMessage : started!.message),
isActive,
elapsed: formatStartupElapsed(endTimestamp - startTimestamp)
}];
});
}
import {
parseNote,
parseNoteLines,
@@ -232,7 +309,10 @@ export class AgentStreamComponent implements AfterViewInit {
});
public isViewingPausedTask = computed(() => {
if (!this.agentService.isPaused()) return false;
// A live task_paused event and the polled runner status must agree before
// recovery controls are shown. This prevents a stale failure trace from
// offering Resume while the active task is still reported as running.
if (!this.agentService.isPaused() || this.agentService.agentStatus() !== 'paused') return false;
const currentSessionId = this.agentService.currentSessionId();
const pausedSessionId = this.agentService.runningSessionId();
@@ -253,8 +333,44 @@ export class AgentStreamComponent implements AfterViewInit {
return groupBlocksToPhases(this.consolidatedBlocks(), currentSession?.start_time || 0);
});
public startupWorkItems = computed(() => {
this.retryClock();
const events = this.agentService.currentStartupProgress();
const agentIsActive = ['running', 'paused'].includes(this.agentService.agentStatus());
return buildStartupWorkItems(
events,
Date.now() / 1000,
this.consolidatedBlocks().length > 0,
agentIsActive
);
});
public startupPreparationIsRunning = computed(() => {
return this.startupWorkItems().some((item) => item.isActive);
});
public startupPreparationIsComplete = computed(() => {
const items = this.startupWorkItems();
return items.length > 0 && !items.some((item) => item.isActive);
});
public startupPreparationDuration = computed(() => {
const items = this.startupWorkItems();
if (items.length === 0) return 0;
const events = this.agentService.currentStartupProgress();
const startedAt = events[0]?.timestamp || items[0].timestamp;
const completedAt = events.find((event) => event.stage === 'environment_ready')?.timestamp
|| events.find((event) => event.stage === 'first_response')?.timestamp;
const endedAt = this.startupPreparationIsRunning()
? Date.now() / 1000
: completedAt || Math.max(...items.map((item) => item.timestamp));
return Math.max(1, Math.round(endedAt - startedAt));
});
private retryablePauseTrace = computed<any | null>(() => {
if (!this.agentService.isPaused()) return null;
if (!this.isViewingPausedTask()) return null;
const blocks = this.consolidatedBlocks();
for (let blockIndex = blocks.length - 1; blockIndex >= 0; blockIndex--) {
@@ -263,11 +379,9 @@ export class AgentStreamComponent implements AfterViewInit {
for (let toolIndex = tools.length - 1; toolIndex >= 0; toolIndex--) {
const tool = tools[toolIndex];
if (
tool?.type === 'llm_call'
&& tool?.status === 'failed'
&& tool?.payload?.pause === true
) {
// Older persisted traces used the llm_pause name without adding the
// newer payload.pause marker. Treat both contracts consistently.
if (this.isDisplayableLLMFailure(tool)) {
return tool;
}
}
@@ -873,10 +987,12 @@ export class AgentStreamComponent implements AfterViewInit {
}
public getLLMErrorText(tool: any): string {
if (!tool) return 'Unknown error';
const noDetails = 'The AI provider did not return error details after the request failed.';
if (!tool) return noDetails;
const rawError = tool.payload?.error || tool.error;
if (!rawError) return 'Unknown error';
return this.cleanErrorMessage(rawError);
if (!rawError) return noDetails;
const cleaned = this.cleanErrorMessage(rawError);
return cleaned === 'Unknown error' ? noDetails : cleaned;
}
public isLLMRetry(tool: any): boolean {
@@ -17,7 +17,6 @@
import { Component, inject, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { AgentService } from '../../services/agent.service';
import { Session } from '../../core/models/session.model';
import { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote } from '../../core/models/markdown.model';
@@ -34,7 +33,6 @@ export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote };
})
export class ChatInterfaceComponent {
public agentService = inject(AgentService);
private http = inject(HttpClient);
public taskInput: string = '';
public isSubmitting: boolean = false;
@@ -79,23 +77,10 @@ export class ChatInterfaceComponent {
this.isSubmitting = true;
this.errorMessage = null;
this.agentService.clearUserPinnedSession();
this.http.post<any>('/api/run', { goal }).subscribe({
this.agentService.runTask(goal).subscribe({
next: (res) => {
this.taskInput = '';
this.isSubmitting = false;
if (res && res.tasks && res.tasks.length > 0) {
const firstTask = res.tasks[0];
if (firstTask && firstTask.session_id) {
// Only auto-select immediately if no task is currently executing/running
const isCurrentlyRunning =
this.agentService.agentStatus() === 'running' ||
this.agentService.sessions().some((s) => s.status === 'running');
if (!isCurrentlyRunning) {
this.agentService.selectSession(firstTask.session_id, false);
}
}
}
// Fetch status to refresh sessions list and select new session
this.agentService.fetchStatus();
},
@@ -31,6 +31,7 @@ export interface DeviceInfo {
product: string | null;
android_version: string | null;
screen_resolution: string | null;
is_locked: boolean | null;
is_emulator: boolean;
installed_packages?: string[];
}
@@ -937,7 +937,7 @@
}
<!-- STATE B-3: NO DEVICE DETECTED / READY TO CONNECT GUIDE -->
@if (!isDeviceReady() && !isEmulatorLaunching() && adbProbe()?.summary !== 'Device Booting' && adbProbe()?.summary !== 'Device Unauthorized' && adbProbe()?.status !== 'fail' && emulatorLaunchState()?.status !== 'failed') {
@if (!isDeviceReady() && !isEmulatorLaunching() && adbProbe()?.summary !== 'Device Booting' && adbProbe()?.summary !== 'Device Unauthorized' && adbProbe()?.summary !== 'Device Locked' && adbProbe()?.summary !== 'Lock State Unknown' && adbProbe()?.status !== 'fail' && emulatorLaunchState()?.status !== 'failed') {
<div class="foolproof-guide-container">
<!-- Method Selection Nav Cards -->
<div class="connection-methods-nav">
@@ -1357,6 +1357,27 @@
</div>
}
<!-- STATE C: CONNECTED DEVICE IS LOCKED / LOCK STATE UNVERIFIED -->
@if (adbProbe()?.status === 'warn' && (adbProbe()?.summary === 'Device Locked' || adbProbe()?.summary === 'Lock State Unknown')) {
<div class="device-locked-panel">
<div class="device-locked-card">
<span class="material-symbols-outlined alert-icon">lock</span>
<div class="locked-details">
<div class="locked-title">
{{ adbProbe()?.summary === 'Device Locked' ? 'Device Is Locked' : 'Unable to Verify Lock State' }}
</div>
<div class="locked-desc">
{{ adbProbe()?.description }}
</div>
</div>
<button type="button" class="btn-check-unlock" [disabled]="isRefreshingDiagnostics()" (click)="refreshReadiness()">
<span class="material-symbols-outlined" [class.spinning]="isRefreshingDiagnostics()">refresh</span>
<span>{{ isRefreshingDiagnostics() ? 'Checking...' : 'Check Again' }}</span>
</button>
</div>
</div>
}
<!-- STATE C: UNAUTHORIZED DEVICE -->
@if (adbProbe()?.status === 'warn' && adbProbe()?.summary === 'Device Unauthorized') {
<div class="unauthorized-panel">
@@ -1478,8 +1499,12 @@
<!-- Prerequisites Drop Alert if any blocker probe is not passing -->
@if (!isReady()) {
<div class="launcher-warning-bar" (click)="setTab('diagnostics')">
<span class="material-symbols-outlined warn-icon">warning</span>
<span>Prerequisites incomplete: runtime, credentials, or Android device is not ready. Click here to open Setup Guide.</span>
<span class="material-symbols-outlined warn-icon">{{ adbProbe()?.summary === 'Device Locked' ? 'lock' : 'warning' }}</span>
<span>
{{ adbProbe()?.summary === 'Device Locked'
? 'Android device is locked. Unlock it and enter the home screen before running a task.'
: 'Prerequisites incomplete: runtime, credentials, or Android device is not ready. Click here to open Setup Guide.' }}
</span>
<span class="material-symbols-outlined arrow">arrow_forward</span>
</div>
}
@@ -1584,7 +1609,8 @@
type="button"
class="btn-run-composer"
[class.btn-run-pro]="selectedProfile() === 'pro'"
[disabled]="!taskGoal().trim() || isSubmitting()"
[disabled]="!taskGoal().trim() || isSubmitting() || !isReady()"
[title]="!isReady() ? (adbProbe()?.summary === 'Device Locked' ? 'Unlock the Android device before running' : 'Complete System Setup before running') : 'Run task'"
(click)="runTask()"
>
@if (isSubmitting()) {
@@ -3089,9 +3089,11 @@ $text-light: #94a3b8;
}
}
/* State C: Unauthorized Panel */
.unauthorized-panel {
.unauthorized-card {
/* State C: Locked / Unauthorized Device Panels */
.unauthorized-panel,
.device-locked-panel {
.unauthorized-card,
.device-locked-card {
display: flex;
align-items: center;
gap: 12px;
@@ -3106,25 +3108,29 @@ $text-light: #94a3b8;
flex-shrink: 0;
}
.unauth-details {
.unauth-details,
.locked-details {
flex: 1;
min-width: 0;
.unauth-title {
.unauth-title,
.locked-title {
font-size: 13px;
font-weight: 600;
color: $amber-dark;
margin-bottom: 2px;
}
.unauth-desc {
.unauth-desc,
.locked-desc {
font-size: 11.5px;
color: #78350f;
line-height: 1.4;
}
}
.btn-retry-auth {
.btn-retry-auth,
.btn-check-unlock {
display: inline-flex;
align-items: center;
gap: 4px;
@@ -3144,7 +3150,7 @@ $text-light: #94a3b8;
flex-shrink: 0;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
&:hover {
&:hover:not(:disabled) {
background: rgba(253, 230, 138, 0.95);
border-color: $amber;
transform: translateY(-0.5px);
@@ -3154,6 +3160,11 @@ $text-light: #94a3b8;
&:active {
transform: scale(0.97);
}
&:disabled {
cursor: not-allowed;
opacity: 0.65;
}
}
}
}
@@ -17,7 +17,6 @@
import { Component, HostListener, inject, computed, signal, ViewChild, ElementRef, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { AgentStreamComponent } from '../../components/agent-stream/agent-stream.component';
import { ChatInterfaceComponent } from '../../components/chat-interface/chat-interface.component';
import { FloatingVideoPlayerComponent } from '../../components/floating-video-player/floating-video-player.component';
@@ -38,7 +37,6 @@ import { AgentService } from '../../services/agent.service';
})
export class WorkspaceComponent implements OnInit {
public agentService = inject(AgentService);
private http = inject(HttpClient);
// Default right panel width to 1/3 of the screen (or 450px as fallback)
public rightPanelWidth = typeof window !== 'undefined' ? Math.round(window.innerWidth / 3) : 450;
@@ -197,25 +195,13 @@ export class WorkspaceComponent implements OnInit {
}
this.isInputFocused.set(false);
this.agentService.clearUserPinnedSession();
this.http.post<any>('/api/run', { goal, profile: this.selectedProfile() }).subscribe({
this.agentService.runTask(goal, this.selectedProfile()).subscribe({
next: (res) => {
this.taskInput = '';
if (this.dockInputRef?.nativeElement) {
this.dockInputRef.nativeElement.style.height = 'auto';
}
this.isSubmitting = false;
if (res && res.tasks && res.tasks.length > 0) {
const firstTask = res.tasks[0];
if (firstTask && firstTask.session_id) {
const isCurrentlyRunning =
this.agentService.agentStatus() === 'running' ||
this.agentService.sessions().some((s) => s.status === 'running');
if (!isCurrentlyRunning) {
this.agentService.selectSession(firstTask.session_id, false);
}
}
}
this.agentService.fetchStatus();
},
error: (err) => {
@@ -7,9 +7,38 @@ describe('AgentService live LLM retry timeline', () => {
function createServiceWithoutPolling(): AgentService {
const service = Object.create(AgentService.prototype) as AgentService;
service.sessionLogs = signal<any[]>([]);
service.startupProgressBySession = signal({});
(service as any).pendingStartupProgress = signal<any[]>([]);
return service;
}
it('orders startup milestones and replaces duplicate stages', () => {
const service = createServiceWithoutPolling();
(service as any).appendStartupProgress({
session_id: 'session-1',
stage: 'device',
message: 'Checking device',
timestamp: 102
}, 'session-1');
(service as any).appendStartupProgress({
session_id: 'session-1',
stage: 'queued',
message: 'Task queued',
timestamp: 100
}, 'session-1');
(service as any).appendStartupProgress({
session_id: 'session-1',
stage: 'device',
message: 'Device connected',
timestamp: 103
}, 'session-1');
const events = service.startupProgressBySession()['session-1'];
expect(events.map((event) => event.stage)).toEqual(['queued', 'device']);
expect(events[1].message).toBe('Device connected');
});
it('normalizes a live retry event into the historical trace contract', () => {
const service = createServiceWithoutPolling();
const event = {
@@ -107,6 +136,42 @@ describe('AgentService live LLM retry timeline', () => {
expect(selectSpy).toHaveBeenCalledWith('new-session', false);
});
it('keeps the paused state when the backend says there is nothing to resume', () => {
const service = createServiceWithoutPolling();
(service as any).http = { post: () => of({ status: 'not_paused' }) };
(service as any).rawSessions = signal<any[]>([{ session_id: 'session-1', status: 'paused' }]);
(service as any).pendingQueue = signal<any[]>([]);
service.agentStatus = signal('paused');
service.runningSessionId = signal<string | null>('session-1');
service.isPaused = signal(true);
service.pausedError = signal<string | null>('503 unavailable');
const statusSpy = spyOn(service, 'fetchStatus');
service.resumeTask();
expect(service.agentStatus()).toBe('paused');
expect(service.isPaused()).toBeTrue();
expect(statusSpy).toHaveBeenCalled();
});
it('moves the active session to running only after resume succeeds', () => {
const service = createServiceWithoutPolling();
(service as any).http = { post: () => of({ status: 'resumed' }) };
(service as any).rawSessions = signal<any[]>([{ session_id: 'session-1', status: 'paused' }]);
(service as any).pendingQueue = signal<any[]>([]);
service.agentStatus = signal('paused');
service.runningSessionId = signal<string | null>('session-1');
service.isPaused = signal(true);
service.pausedError = signal<string | null>('503 unavailable');
spyOn(service, 'fetchStatus');
service.resumeTask();
expect(service.agentStatus()).toBe('running');
expect(service.isPaused()).toBeFalse();
expect((service as any).rawSessions()[0].status).toBe('running');
});
});
describe('AgentService recording finalization lifecycle', () => {
@@ -38,6 +38,13 @@ export type RecordingPlaybackStatus =
| 'failed'
| 'unavailable';
export interface StartupProgressEvent {
session_id?: string;
stage: string;
message: string;
timestamp: number;
}
interface SessionVideoResponse {
session_id: string;
status?: 'processing' | 'ready' | 'failed' | 'unavailable';
@@ -90,7 +97,7 @@ export class AgentService {
const isCurrentRunning = (status === 'running' || status === 'paused') && runId === p.session_id;
sessionMap.set(p.session_id, {
...p,
status: isCurrentRunning ? status : 'pending'
status: isCurrentRunning ? status : (p.status || 'pending')
});
}
});
@@ -120,6 +127,17 @@ export class AgentService {
public retryMessage = signal<string | null>(null);
public activeModel = signal<{ name: string; id: string; provider: string } | null>(null);
public userPinnedSessionId = signal<string | null>(null); // Pinned session if user explicitly selected a non-running task
public startupProgressBySession = signal<Record<string, StartupProgressEvent[]>>({});
private pendingStartupProgress = signal<StartupProgressEvent[]>([]);
public currentStartupProgress = computed(() => {
const sessionId = this.currentSessionId();
if (sessionId) {
const sessionEvents = this.startupProgressBySession()[sessionId];
if (sessionEvents?.length) return sessionEvents;
}
return this.pendingStartupProgress();
});
// Notes and Tab States
public currentNotes = signal<Record<string, string>>({});
@@ -210,6 +228,12 @@ export class AgentService {
enableOutputter?: boolean
): Observable<any> {
return new Observable((obs) => {
const submittedEvent: StartupProgressEvent = {
stage: 'submitting',
message: 'Submitting the task',
timestamp: Date.now() / 1000
};
this.pendingStartupProgress.set([submittedEvent]);
const payload: any = { goal, profile };
if (expectedOutput && expectedOutput.trim()) {
payload.expected_output = expectedOutput.trim();
@@ -223,6 +247,11 @@ export class AgentService {
if (res && res.tasks && res.tasks.length > 0) {
const newSessionId = res.tasks[0].session_id;
if (newSessionId) {
this.appendStartupProgress(
{ ...submittedEvent, session_id: newSessionId },
newSessionId
);
this.pendingStartupProgress.set([]);
const isCurrentlyRunning =
this.agentStatus() === 'running' ||
this.sessions().some((s) => s.status === 'running');
@@ -236,10 +265,12 @@ export class AgentService {
}
}
}
this.pendingStartupProgress.set([]);
obs.next(res);
obs.complete();
},
error: (err) => {
this.pendingStartupProgress.set([]);
obs.error(err);
}
});
@@ -250,6 +281,16 @@ export class AgentService {
* Stop the currently running task (and optionally clear queue)
*/
public stopTask(stopAll: boolean = false): void {
const stoppingSessionId = this.runningSessionId()
|| this.sessions().find((session) => session.status === 'running' || session.status === 'paused')?.session_id
|| null;
// Keep the task's terminal state stable while the stop request and the
// persisted session update cross the network. Clearing the global runner
// state first used to make the session merger infer "completed" from the
// still-stale DB row, causing a brief completed -> cancelled flicker.
this.setSessionStatus(stoppingSessionId, 'cancelled');
// Apply optimistic updates: only set idle if stopAll is true or pending queue is empty
if (stopAll || this.pendingQueue().length === 0) {
this.agentStatus.set('idle');
@@ -290,10 +331,19 @@ export class AgentService {
* Resume paused task
*/
public resumeTask(): void {
this.http.post('/api/resume', {}).subscribe({
next: () => {
this.http.post<{ status?: string }>('/api/resume', {}).subscribe({
next: (response) => {
if (response?.status !== 'resumed') {
// A stale recovery control must not optimistically change the task
// back to running when the backend says there is nothing to resume.
this.fetchStatus();
return;
}
const resumedSessionId = this.runningSessionId();
this.isPaused.set(false);
this.pausedError.set(null);
this.agentStatus.set('running');
this.setSessionStatus(resumedSessionId, 'running');
this.fetchStatus();
},
error: (err) => {
@@ -473,6 +523,7 @@ export class AgentService {
'task_paused',
'task_resumed',
'llm_retrying',
'startup_progress',
'session_started',
'session_ended',
'recording_ready',
@@ -523,6 +574,7 @@ export class AgentService {
this.selectSession(parsedData.session_id, false);
}
} else if (eventType === 'session_ended') {
this.applySessionEndedStatus(evtSessionId, parsedData);
this.fetchStatus();
this.fetchSessions();
this.updateModelForCurrentView();
@@ -548,6 +600,7 @@ export class AgentService {
}
if (eventType === 'session_ended') {
this.applySessionEndedStatus(sessionId, parsedData);
this.agentStatus.set('idle');
this.runningSessionId.set(null);
this.runningGoal.set(null);
@@ -566,6 +619,11 @@ export class AgentService {
return;
}
if (eventType === 'startup_progress') {
this.appendStartupProgress(parsedData, sessionId);
return;
}
if (eventType === 'llm_retrying') {
this.isRetrying.set(true);
const attempt = Number(parsedData.attempt || 0);
@@ -581,6 +639,9 @@ export class AgentService {
if (eventType === 'task_paused') {
this.isPaused.set(true);
this.isRetrying.set(false);
this.agentStatus.set('paused');
this.runningSessionId.set(sessionId);
this.setSessionStatus(sessionId, 'paused');
const pauseError = parsedData.error || 'AI call failed';
this.pausedError.set(pauseError);
this.appendPausedErrorCard(
@@ -597,6 +658,10 @@ export class AgentService {
this.isPaused.set(false);
this.isRetrying.set(false);
this.pausedError.set(null);
if (this.runningSessionId() === sessionId) {
this.agentStatus.set('running');
this.setSessionStatus(sessionId, 'running');
}
this.activePauseCardKey = null;
return;
}
@@ -888,6 +953,34 @@ export class AgentService {
private pollCounter: number = 0;
private setSessionStatus(sessionId: string | null, status: Session['status']): void {
if (!sessionId) return;
this.rawSessions.update((sessions) => sessions.map((session) =>
session.session_id === sessionId ? { ...session, status } : session
));
this.pendingQueue.update((sessions) => sessions.map((session) =>
session.session_id === sessionId ? { ...session, status } : session
));
}
private applySessionEndedStatus(sessionId: unknown, data: any): void {
if (!sessionId) return;
const reportedStatus = String(data?.status || '').toLowerCase();
const status: Session['status'] | null = data?.was_stopped_manually || reportedStatus === 'cancelled'
? 'cancelled'
: reportedStatus === 'failed'
? 'failed'
: reportedStatus === 'completed' || reportedStatus === 'success'
? 'completed'
: null;
if (status) {
this.setSessionStatus(String(sessionId), status);
}
}
/**
* Start periodic status polling from the backend
*/
@@ -1114,6 +1207,32 @@ export class AgentService {
this.requestSessionVideo(targetSessionId, this.videoRequestGeneration);
}
private appendStartupProgress(data: any, sessionId: string): void {
if (!data?.stage || !data?.message) return;
const timestamp = typeof data.timestamp === 'number'
? (data.timestamp > 1e11 ? data.timestamp / 1000 : data.timestamp)
: Date.now() / 1000;
const event: StartupProgressEvent = {
session_id: String(data.session_id || sessionId),
stage: String(data.stage),
message: String(data.message),
timestamp
};
this.startupProgressBySession.update((allEvents) => {
const current = [...(allEvents[sessionId] || [])];
const existingIndex = current.findIndex((item) => item.stage === event.stage);
if (existingIndex >= 0) {
current[existingIndex] = event;
} else {
current.push(event);
}
current.sort((a, b) => a.timestamp - b.timestamp);
return { ...allEvents, [sessionId]: current };
});
}
private beginRecordingFinalization(sessionId: string): void {
if (this.activeVideoSessionId !== sessionId) return;
this.cancelVideoRetry();
@@ -1,4 +1,15 @@
import { getUniqueGenericTools } from './tool-formatter.util';
import { cleanErrorMessage, getUniqueGenericTools } from './tool-formatter.util';
describe('cleanErrorMessage', () => {
it('removes repeated LLM wrapper labels while preserving the provider reason', () => {
expect(cleanErrorMessage('LLM Error: LLM Request Error: 503 model overloaded'))
.toBe('503 model overloaded');
});
it('does not present an empty LLM wrapper label as an error reason', () => {
expect(cleanErrorMessage('LLM Error:')).toBe('Unknown error');
});
});
describe('getUniqueGenericTools retry aggregation', () => {
it('groups recoverable LLM retries while preserving every attempt', () => {
@@ -1022,7 +1022,11 @@ export function cleanErrorMessage(rawError: any): string {
.replace(/^Pixel-level validation failed:\s*/i, '')
.replace(/^Execution error:\s*/i, '')
.replace(/^ServerError:\s*/i, '')
// LLM wrappers may add this prefix more than once. It is context, not the
// actual provider reason, and an empty prefix must not be shown as though
// it were a useful error message.
.replace(/^(?:LLM\s+(?:Request\s+)?Error\s*:\s*)+/i, '')
.trim();
return fallback || errorStr;
return fallback || 'Unknown error';
}
+76 -14
View File
@@ -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
+14 -168
View File
@@ -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")
+4
View File
@@ -91,6 +91,10 @@ class ReadinessEngine:
return None
return await probe.probe()
async def run_device_submission_probe(self) -> ProbeResult:
"""Run the bounded device gate used by task submission."""
return await self._adb_probe.probe_submission_readiness()
async def run_all(self, categories: list[ProbeCategory] | None = None) -> SystemReadinessReport:
"""Concurrently run all registered diagnostic probes and compile report."""
toolchain.clear_cache()
@@ -17,6 +17,7 @@
import asyncio
import os
from pathlib import Path
import re
import shutil
import subprocess
from typing import Any
@@ -120,6 +121,242 @@ class AdbDeviceProbe(BaseProbe):
logger.debug(f"Failed to read adb version: {e}")
return "Installed"
@staticmethod
def _parse_device_lock_state(policy_output: str, trust_output: str) -> bool | None:
"""Parse Android Keyguard state from dumpsys output.
``KeyguardServiceDelegate.showing`` also catches swipe-only lock screens,
while Trust's current-user ``deviceLocked`` covers secure locks and
screen-off transitions. Any positive signal wins so an occluded lock
screen cannot accidentally be treated as ready.
"""
policy_showing: bool | None = None
policy_lines = policy_output.splitlines()
for index, raw_line in enumerate(policy_lines):
if raw_line.strip() != "KeyguardServiceDelegate":
continue
for delegate_line in policy_lines[index + 1 : index + 16]:
match = re.fullmatch(
r"showing\s*=\s*(true|false|1|0)",
delegate_line.strip(),
flags=re.IGNORECASE,
)
if match:
policy_showing = match.group(1).lower() in {"true", "1"}
break
break
# Older Android releases expose differently named policy fields.
legacy_matches = re.findall(
r"\b(?:mShowingLockscreen|mKeyguardShowing|keyguardShowing|"
r"isKeyguardLocked|showingAndNotOccluded)\s*=\s*(true|false|1|0)",
policy_output,
flags=re.IGNORECASE,
)
legacy_states = [value.lower() in {"true", "1"} for value in legacy_matches]
trust_match = re.search(
r"^.*\(current\).*?\bdeviceLocked\s*=\s*(true|false|1|0)\b",
trust_output,
flags=re.IGNORECASE | re.MULTILINE,
)
if trust_match is None:
trust_match = re.search(
r"\bdeviceLocked\s*=\s*(true|false|1|0)\b",
trust_output,
flags=re.IGNORECASE,
)
trust_locked = (
trust_match.group(1).lower() in {"true", "1"} if trust_match else None
)
states = [state for state in [policy_showing, trust_locked, *legacy_states] if state is not None]
if any(states):
return True
if states:
return False
return None
async def _get_device_lock_state(
self,
adb_path: str,
serial: str,
timeout_seconds: float = 2.0,
) -> bool | None:
"""Query Keyguard state without changing or waking the target device."""
async def run_dumpsys(*service_args: str) -> str:
proc = await asyncio.create_subprocess_exec(
adb_path,
"-s",
serial,
"shell",
"dumpsys",
*service_args,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
try:
stdout, _ = await asyncio.wait_for(
proc.communicate(), timeout=timeout_seconds
)
except TimeoutError:
proc.kill()
await proc.communicate()
return ""
if proc.returncode != 0:
return ""
return stdout.decode(errors="replace")
try:
policy_output, trust_output = await asyncio.gather(
run_dumpsys("window", "policy"),
run_dumpsys("trust"),
)
return self._parse_device_lock_state(policy_output, trust_output)
except Exception as exc:
logger.debug(f"Failed to query lock state for {serial}: {exc}")
return None
async def _get_device_states(
self,
adb_path: str,
timeout_seconds: float = 1.0,
) -> list[tuple[str, str]] | None:
"""Return only ADB serial/state pairs for the submission fast path.
``None`` means the command could not be completed within the bounded
submission window. An empty list means ADB responded with no devices.
"""
proc = None
try:
proc = await asyncio.create_subprocess_exec(
adb_path,
"devices",
"-l",
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
stdout, _ = await asyncio.wait_for(
proc.communicate(), timeout=timeout_seconds
)
if proc.returncode != 0:
return None
states: list[tuple[str, str]] = []
lines = stdout.decode(errors="replace").splitlines()
for raw_line in lines[1:]:
parts = raw_line.strip().split()
if len(parts) >= 2:
states.append((parts[0], parts[1]))
return states
except TimeoutError:
if proc is not None:
proc.kill()
await proc.communicate()
return None
except Exception as exc:
logger.debug(f"Failed submission-time ADB device check: {exc}")
return None
async def probe_submission_readiness(self) -> ProbeResult:
"""Run the minimal fail-safe device check required before enqueueing.
The full diagnostics probe enriches device metadata, scans packages,
and discovers emulators. None of that is needed to reject a locked
device at submission time, so this path only checks ADB connectivity
and Keyguard state with strict time bounds.
"""
adb_path = toolchain.resolve("adb")
if not adb_path:
return ProbeResult(
id=self.probe_id,
category=self.category,
title="Device / Emulator Connected",
status=ProbeStatus.FAIL,
is_blocker=self.is_blocker,
summary="ADB Not Found",
description="Android Debug Bridge (adb) is not installed or not in PATH.",
metadata={"installed": False, "submission_probe": True},
)
device_states = await self._get_device_states(adb_path)
if device_states is None:
return ProbeResult(
id=self.probe_id,
category=self.category,
title="Device / Emulator Connected",
status=ProbeStatus.WARN,
is_blocker=self.is_blocker,
summary="Lock State Unknown",
description=(
"ADB did not respond quickly enough to verify the Android lock-screen state."
),
metadata={"installed": True, "submission_probe": True},
)
ready_serials = [serial for serial, state in device_states if state == "device"]
if not ready_serials:
states = {state for _, state in device_states}
summary = "Device Unauthorized" if "unauthorized" in states else "No Device Found"
return ProbeResult(
id=self.probe_id,
category=self.category,
title="Device / Emulator Connected",
status=ProbeStatus.WARN,
is_blocker=self.is_blocker,
summary=summary,
description="No authorized Android device is currently ready.",
metadata={
"installed": True,
"submission_probe": True,
"devices": [
{"serial": serial, "state": state}
for serial, state in device_states
],
},
)
serial = (
self._target_serial
if self._target_serial in ready_serials
else ready_serials[0]
)
is_locked = await self._get_device_lock_state(
adb_path,
serial,
timeout_seconds=1.0,
)
summary = (
"Device Locked"
if is_locked is True
else "Connected"
if is_locked is False
else "Lock State Unknown"
)
status = ProbeStatus.PASS if is_locked is False else ProbeStatus.WARN
description = (
f"ADB connected to {serial}. Ready for task submission."
if is_locked is False
else f"Android device {serial} is locked. Unlock it before running a task."
if is_locked is True
else f"Android lock-screen state could not be verified for {serial}."
)
return ProbeResult(
id=self.probe_id,
category=self.category,
title="Device / Emulator Connected",
status=status,
is_blocker=self.is_blocker,
summary=summary,
description=description,
metadata={
"installed": True,
"submission_probe": True,
"active_device": {"serial": serial, "is_locked": is_locked},
},
)
async def _parse_adb_devices(self, adb_path: str) -> list[DeviceInfo]:
"""Execute `adb devices -l` and extract structured device metadata."""
devices: list[DeviceInfo] = []
@@ -197,8 +434,12 @@ class AdbDeviceProbe(BaseProbe):
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
lock_state_task = asyncio.create_task(
self._get_device_lock_state(adb_path, dev.serial)
)
out_prop, _ = await p_prop.communicate()
out_size, _ = await p_size.communicate()
dev.is_locked = await lock_state_task
if out_prop:
dev.android_version = out_prop.decode(errors="replace").strip()
@@ -430,6 +671,37 @@ class AdbDeviceProbe(BaseProbe):
metadata["active_device"] = active_dev.model_dump()
if active_dev.is_locked is not False:
is_locked = active_dev.is_locked is True
summary = "Device Locked" if is_locked else "Lock State Unknown"
description = (
f"ADB connected to {display_name}, but the Android lock screen is active. "
"Unlock the device and enter the home screen before running a task."
if is_locked
else f"ADB connected to {display_name}, but Android lock-screen state could not "
"be verified. Keep the device unlocked on the home screen and check again."
)
return ProbeResult(
id=self.probe_id,
category=self.category,
title="Device / Emulator Connected",
status=ProbeStatus.WARN,
is_blocker=self.is_blocker,
summary=summary,
description=description,
metadata=metadata,
actions=[
ProbeAction(
action_type="hint",
label="Unlock Device" if is_locked else "Verify Device Screen",
payload=(
"Unlock the Android device and leave it on the home screen. "
"Readiness is checked automatically every few seconds."
),
)
],
)
return ProbeResult(
id=self.probe_id,
category=self.category,
+4
View File
@@ -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"
+9
View File
@@ -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())
+29 -22
View File
@@ -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()
+4
View File
@@ -23,6 +23,7 @@ from typing import Annotated
from adbutils import AdbClient
from langchain_core.callbacks.base import Callbacks
from artemis.config import initialize_llm_config, settings
from artemis.utils.startup_progress import publish_startup_progress
from artemis import Agent, Builders
from artemis.sdk.types.task import AgentProfile
from artemis.utils.cli_helpers import display_device_status
@@ -73,6 +74,7 @@ async def execute_task(
explorer_flash_mode: Override Explorer version mode for Flash execution profile.
explorer_pro_mode: Override Explorer version mode for Pro execution profile.
"""
publish_startup_progress("configuration", "Loading the run configuration")
llm_config = initialize_llm_config()
agent_profile = AgentProfile(name="default", llm_config=llm_config)
config = Builders.AgentConfig.with_default_profile(profile=agent_profile)
@@ -117,11 +119,13 @@ async def execute_task(
agent: Agent | None = None
try:
publish_startup_progress("device_check", "Checking the Android device")
agent = Agent(config=config.build())
await agent.init(
retry_count=int(os.getenv("ARTEMIS_HEALTH_RETRIES", 5)),
retry_wait_seconds=int(os.getenv("ARTEMIS_HEALTH_DELAY", 2)),
)
publish_startup_progress("device_ready", "Android device connected")
task = agent.new_task(goal)
if locked_app_package:
+15 -6
View File
@@ -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")
+7 -1
View File
@@ -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()
+7 -1
View File
@@ -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()
+10
View File
@@ -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",
]
+288
View File
@@ -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
+300
View File
@@ -0,0 +1,300 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""USB-scoped screen-awake policy shared by every local Artemis entrypoint."""
from __future__ import annotations
import os
import re
import subprocess
import threading
from typing import Iterable
from adbutils import AdbClient
from artemis.config import settings
from artemis.runtime.awake_lease import ScreenAwakeLease
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
AWAKE_STRATEGY_USB = "usb_stay_on"
AWAKE_STRATEGY_HEARTBEAT = "host_heartbeat"
AWAKE_HEARTBEAT_INTERVAL_SECONDS = 5.0
DEVICE_DISCOVERY_INTERVAL_SECONDS = 2.0
def _awake_enabled() -> bool:
enabled = os.environ.get("ARTEMIS_KEEP_DEVICE_AWAKE", "true").strip().lower()
return enabled not in {"0", "false", "no", "off"}
def _run_awake_adb_command(
device_id: str, args: list[str], description: str
) -> subprocess.CompletedProcess[str] | None:
"""Run one non-fatal ADB command used by the USB awake policy."""
try:
result = subprocess.run(
["adb", "-s", device_id, *args],
capture_output=True,
text=True,
timeout=10,
check=False,
)
if result.returncode != 0:
detail = (result.stderr or result.stdout).strip()
logger.warning(
f"Could not {description} on {device_id}: {detail or 'ADB command failed'}"
)
return result
except Exception as exc:
logger.warning(f"Could not {description} on {device_id}: {exc}")
return None
def _configure_usb_stay_awake(device_id: str) -> str | None:
"""Enable USB stay-awake, falling back to an effective host heartbeat.
The policy is intentionally scoped to USB power rather than an Artemis
process or task. Android applies the normal screen timeout as soon as USB
power is removed, even though no ADB command can be sent after unplugging.
AC, wireless, and dock charging retain the user's normal behavior.
"""
if not _awake_enabled() or os.environ.get("ARTEMIS_CLOUD_MODE") == "1":
return None
# Migration cleanup: previous Artemis versions used a persistent shell
# wake lock. Drain it only after every live legacy owner has exited.
ScreenAwakeLease(device_id).cleanup_unowned_references()
commands = (
(
"enable USB-scoped stay-awake policy",
["shell", "svc", "power", "stayon", "usb"],
),
("wake display", ["shell", "input", "keyevent", "KEYCODE_WAKEUP"]),
("dismiss non-secure keyguard", ["shell", "wm", "dismiss-keyguard"]),
)
for description, args in commands:
_run_awake_adb_command(device_id, args, description)
configured = _run_awake_adb_command(
device_id,
["shell", "settings", "get", "global", "stay_on_while_plugged_in"],
"verify USB-scoped stay-awake policy",
)
power_state = _run_awake_adb_command(
device_id,
["shell", "dumpsys", "power"],
"verify active USB stay-awake state",
)
try:
value = int(configured.stdout.strip()) if configured is not None else -1
except (TypeError, ValueError):
value = -1
stay_on_active = (
configured is not None
and configured.returncode == 0
and value == 2
and power_state is not None
and power_state.returncode == 0
and re.search(r"\bmStayOn=true\b", power_state.stdout) is not None
)
if stay_on_active:
logger.info(f"USB-scoped stay-awake policy verified active on {device_id}")
return AWAKE_STRATEGY_USB
logger.warning(
f"USB stay-awake is not active on {device_id} (setting={value}); "
f"using a {AWAKE_HEARTBEAT_INTERVAL_SECONDS:g}-second host heartbeat"
)
primed = _run_awake_adb_command(
device_id,
["shell", "input", "keyevent", "KEYCODE_UNKNOWN"],
"prime the host stay-awake heartbeat",
)
if primed is None or primed.returncode != 0:
return None
return AWAKE_STRATEGY_HEARTBEAT
def _discover_connected_device_ids() -> list[str]:
"""Return the selected or first connected local Android device."""
if not _awake_enabled() or os.environ.get("ARTEMIS_CLOUD_MODE") == "1":
return []
target = os.environ.get("ARTEMIS_DEVICE_ID") or os.environ.get("ADB_DEVICE_SERIAL")
host = os.environ.get("ADB_HOST") or settings.ADB_HOST or "127.0.0.1"
port_text = os.environ.get("ADB_PORT")
try:
port = int(port_text) if port_text else int(settings.ADB_PORT or 5037)
except (TypeError, ValueError):
port = 5037
try:
devices = AdbClient(host=host, port=port).device_list()
serials = [device.serial for device in devices if device.serial]
except Exception as exc:
logger.debug(f"Could not discover a device for the USB awake policy: {exc}")
return []
if target:
return [target] if target in serials else []
# Artemis has a single-device execution policy. Do not modify unrelated
# devices that happen to share the ADB server.
return serials[:1]
class ScreenAwakeService:
"""Apply USB stay-awake or a connection-scoped host heartbeat."""
def __init__(self):
self._lock = threading.RLock()
self._strategies: dict[str, str] = {}
self._heartbeat_stops: dict[str, threading.Event] = {}
self._heartbeat_threads: dict[str, threading.Thread] = {}
self._monitor_stop: threading.Event | None = None
self._monitor_thread: threading.Thread | None = None
def start(self, device_ids: Iterable[str] | None = None) -> dict[str, str | None]:
selected = list(device_ids) if device_ids is not None else _discover_connected_device_ids()
results = {
device_id: self.ensure_device(device_id)
for device_id in dict.fromkeys(selected)
if device_id
}
if _awake_enabled() and os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
self._start_device_monitor()
return results
def ensure_device(self, device_id: str) -> str | None:
"""Configure one device once per process; Android owns unplug behavior."""
if not device_id or not _awake_enabled() or os.environ.get("ARTEMIS_CLOUD_MODE") == "1":
return None
with self._lock:
existing = self._strategies.get(device_id)
if existing is not None:
return existing
strategy = _configure_usb_stay_awake(device_id)
if strategy is not None:
self._strategies[device_id] = strategy
if strategy == AWAKE_STRATEGY_HEARTBEAT:
self._start_heartbeat(device_id)
return strategy
def _start_heartbeat(self, device_id: str) -> None:
if device_id in self._heartbeat_threads:
return
stop_event = threading.Event()
def heartbeat() -> None:
while not stop_event.wait(AWAKE_HEARTBEAT_INTERVAL_SECONDS):
_run_awake_adb_command(
device_id,
["shell", "input", "keyevent", "KEYCODE_UNKNOWN"],
"send the host stay-awake heartbeat",
)
thread = threading.Thread(
target=heartbeat,
name=f"artemis-awake-heartbeat-{device_id}",
daemon=True,
)
self._heartbeat_stops[device_id] = stop_event
self._heartbeat_threads[device_id] = thread
thread.start()
def _start_device_monitor(self) -> None:
with self._lock:
if self._monitor_thread is not None and self._monitor_thread.is_alive():
return
stop_event = threading.Event()
def monitor() -> None:
while not stop_event.wait(DEVICE_DISCOVERY_INTERVAL_SECONDS):
connected = set(_discover_connected_device_ids())
if stop_event.is_set():
break
self._reconcile_connected_devices(connected)
thread = threading.Thread(
target=monitor,
name="artemis-awake-device-monitor",
daemon=True,
)
self._monitor_stop = stop_event
self._monitor_thread = thread
thread.start()
def _reconcile_connected_devices(self, connected: set[str]) -> None:
"""Stop unplugged heartbeats and enroll newly attached devices."""
with self._lock:
missing = set(self._strategies) - connected
for device_id in missing:
self._stop_device(device_id)
for device_id in connected:
self.ensure_device(device_id)
def _stop_device(self, device_id: str) -> None:
with self._lock:
stop_event = self._heartbeat_stops.pop(device_id, None)
thread = self._heartbeat_threads.pop(device_id, None)
self._strategies.pop(device_id, None)
if stop_event is not None:
stop_event.set()
if thread is not None and thread is not threading.current_thread():
thread.join(timeout=2.0)
def shutdown(self) -> None:
"""Stop host work; unplug behavior remains owned by Android's USB policy."""
with self._lock:
monitor_stop = self._monitor_stop
monitor_thread = self._monitor_thread
heartbeat_stops = list(self._heartbeat_stops.values())
heartbeat_threads = list(self._heartbeat_threads.values())
if monitor_stop is not None:
monitor_stop.set()
for stop_event in heartbeat_stops:
stop_event.set()
self._monitor_stop = None
self._monitor_thread = None
self._heartbeat_stops.clear()
self._heartbeat_threads.clear()
self._strategies.clear()
if monitor_thread is not None:
monitor_thread.join(timeout=2.0)
for thread in heartbeat_threads:
thread.join(timeout=2.0)
@property
def device_ids(self) -> tuple[str, ...]:
with self._lock:
return tuple(self._strategies)
screen_awake_service = ScreenAwakeService()
def start_awake_service(device_ids: Iterable[str] | None = None) -> dict[str, str | None]:
return screen_awake_service.start(device_ids)
def ensure_device_awake(device_id: str) -> str | None:
return screen_awake_service.ensure_device(device_id)
def shutdown_awake_service() -> None:
screen_awake_service.shutdown()
+361 -34
View File
@@ -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
+26
View File
@@ -65,6 +65,32 @@ class ProcessSupervisor:
"""Recursively terminate a process tree."""
return platform.process.terminate_process_tree(pid, timeout_seconds=timeout_seconds)
@staticmethod
def terminate_tree_verified(
pid: int,
process_created_at: float,
timeout_seconds: float = 3.0,
) -> bool:
"""Terminate a process tree only if PID and creation time still match.
Cross-entry task control discovers the worker through a shared lease.
Verifying creation time prevents a stale lease from terminating an
unrelated process after the operating system reuses its PID.
"""
try:
import psutil
except Exception:
return False
try:
process = psutil.Process(pid)
if process_created_at > 0 and abs(process.create_time() - process_created_at) >= 1.0:
return False
except psutil.NoSuchProcess:
return True
except Exception:
return False
return ProcessSupervisor.terminate_tree(pid, timeout_seconds=timeout_seconds)
@staticmethod
async def stop_process(
proc: asyncio.subprocess.Process | None,
+91 -16
View File
@@ -14,6 +14,7 @@
import asyncio
import os
import re
try:
from datetime import UTC, datetime
@@ -26,6 +27,7 @@ from platform import system
import shutil
from shutil import which
import sys
import threading
from types import NoneType
from typing import TypeVar, overload
import uuid
@@ -82,6 +84,7 @@ from artemis.utils.media import (
remove_images_from_trace_folder,
remove_steps_json_from_trace_folder,
)
from artemis.utils.startup_progress import publish_startup_progress
logger = get_logger(__name__)
@@ -184,6 +187,7 @@ class Agent:
async def _prewarm_llm_connections(self, api_key: str | None = None):
"""Pre-warms the HTTP2/gRPC connection pools for both Native GenAI and LangChain clients in the background."""
publish_startup_progress("model_warmup", "Warming the model connection")
logger.info("Starting background pre-warming of Gemini API connection pools...")
try:
key = api_key
@@ -192,6 +196,9 @@ class Agent:
if not key:
logger.warning("Skipping LLM pre-warming: No API key available.")
publish_startup_progress(
"model_ready", "Model connection will initialize on first use"
)
return
# 1. Pre-warm Native SDK client
@@ -207,8 +214,12 @@ class Agent:
return_exceptions=True,
)
logger.success("Gemini API connection pools successfully pre-warmed.")
publish_startup_progress("model_ready", "Model connection is ready")
except Exception as e:
logger.warning(f"Failed to pre-warm LLM connections: {e}")
publish_startup_progress(
"model_ready", "Model connection will initialize on first use"
)
async def install_apk(self, apk_path: str | Path) -> None:
"""Install an APK on the connected device.
@@ -447,12 +458,6 @@ class Agent:
agent_config=self._config,
)
self._prepare_tracing(task=task, context=context)
await self._prepare_app_installation(task=task)
await self._prepare_device_environment(context=context)
await self._prepare_app_lock(task=task, context=context)
self._prepare_output_files(task=task)
output_config = None
if request.output_description or request.output_format:
output_config = OutputConfig(
@@ -476,7 +481,46 @@ class Agent:
)
try:
if os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
await asyncio.to_thread(device_lock.acquire)
queue_cancel_event = threading.Event()
acquire_task = asyncio.create_task(
asyncio.to_thread(
device_lock.acquire,
cancel_event=queue_cancel_event,
)
)
try:
await asyncio.shield(acquire_task)
except asyncio.CancelledError:
queue_cancel_event.set()
try:
await asyncio.shield(acquire_task)
except Exception:
pass
raise
# All device mutation and UI initialization happens only after
# this task reaches the head of the one global FIFO queue.
# Session creation must be inside the same boundary as well:
# DataEngine uses a shared database, so a queued task must not
# publish a new active session while the current task is still
# finishing.
if os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
if self._ui_adb_client is not None:
publish_startup_progress(
"uiautomator", "Connecting to UI Automator"
)
await asyncio.to_thread(self._ui_adb_client.connect)
publish_startup_progress(
"uiautomator_ready", "UI Automator is ready"
)
await self._ensure_device_unlocked()
publish_startup_progress(
"environment", "Preparing the device environment"
)
self._prepare_tracing(task=task, context=context)
self._prepare_output_files(task=task)
await self._prepare_app_installation(task=task)
await self._prepare_device_environment(context=context)
await self._prepare_app_lock(task=task, context=context)
async with context:
recording_started = False
if self._config.video_recording_tools_enabled:
@@ -503,6 +547,9 @@ class Agent:
except Exception as e:
logger.error(f"[{task_name}] Failed to start screen recording: {e}")
publish_startup_progress(
"environment_ready", "Device environment is ready"
)
try:
if request.profile and request.profile.lower() == "flash":
logger.info(f"[{task_name}] Invoking FlashRunner reactive loop...")
@@ -645,7 +692,11 @@ class Agent:
await self._finalize_tracing_safely(task=task, context=context)
finally:
if os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
await asyncio.to_thread(device_lock.release)
try:
if self._ui_adb_client is not None:
await asyncio.to_thread(self._ui_adb_client.disconnect)
finally:
await asyncio.to_thread(device_lock.release)
async with self._task_lock:
if self._current_task and not self._current_task.done():
@@ -707,9 +758,35 @@ class Agent:
if not self._initialized and not force:
return
if self._ui_adb_client is not None:
await asyncio.to_thread(self._ui_adb_client.disconnect)
self._initialized = False
logger.info("✅ Artemis agent stopped.")
async def _ensure_device_unlocked(self) -> None:
"""Reject secure keyguard instead of allowing an agent to guess credentials."""
if self._adb_client is None:
raise AgentError("ADB client is not initialized.")
device = self._adb_client.device(serial=self._device_context.device_id)
try:
trust_state = str(await asyncio.to_thread(device.shell, "dumpsys trust"))
except Exception as exc:
logger.warning(f"Could not inspect Android keyguard state: {exc}")
return
# The first deviceLocked value belongs to the current Android user;
# later entries may describe a separately locked work profile.
match = re.search(r"\bdeviceLocked=(?:true|1|false|0)\b", trust_state, re.IGNORECASE)
if match is None:
return
value = match.group(0).split("=", 1)[1].lower()
if value in {"true", "1"}:
raise AgentError(
"Android secure keyguard is locked. Unlock the device manually before "
"running Artemis; automation will not guess a PIN, password, or pattern."
)
async def _prepare_app_installation(self, task: Task) -> str | None:
"""Install app if app_path is specified in the task request.
@@ -1008,19 +1085,17 @@ class Agent:
device_height=height,
)
# Get real device dimensions from the device
if self._ui_adb_client:
# Query dimensions without starting UIAutomator or acquiring an awake
# strategy. Those belong inside the global execution queue lease.
if self._adb_client:
try:
# Use UIAutomator to get actual screen dimensions
screen_data = self._ui_adb_client.get_screen_data()
device_width = screen_data.width
device_height = screen_data.height
device_width, device_height = self._adb_client.device(device_id).window_size()
logger.info(f"Retrieved Android screen dimensions: {device_width}x{device_height}")
except Exception as e:
logger.warning(f"Failed to get Android screen dimensions: {e}, using defaults")
logger.warning(f"Failed to get Android window size: {e}, using defaults")
device_width, device_height = 1080, 2340
else:
logger.warning("UIAutomator client not available, using default dimensions")
logger.warning("ADB client not available, using default dimensions")
device_width, device_height = 1080, 2340
from artemis.platform import platform as pal_platform
+15 -5
View File
@@ -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
+58
View File
@@ -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
+9 -4
View File
@@ -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__":
+1
View File
@@ -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
+17
View File
@@ -21,6 +21,7 @@ import uuid
from mcp_server.base import mcp
from mcp_server.utils import env_utils, trace_store
from artemis.runtime import DeviceExecutionLock
@mcp.tool()
@@ -139,6 +140,11 @@ def mobile_run_task(
# 3. Resolve project root, python executable, and background runner module
project_root = env_utils.get_project_root()
python_exe = env_utils.resolve_python_executable(project_root)
queue_ticket = DeviceExecutionLock.reserve(
description=f"MCP task: {task_desc[:120]}",
session_id=trace_id,
ingress="mcp",
)
# 4. Spawn the background task runner as an independent subprocess
try:
@@ -164,6 +170,8 @@ def mobile_run_task(
env = os.environ.copy()
env["ARTEMIS_SESSION_ID"] = trace_id
env["ARTEMIS_TASK_INGRESS"] = "mcp"
env[DeviceExecutionLock.QUEUE_TICKET_ENV] = queue_ticket
proc_kwargs = env_utils.get_detached_process_kwargs()
proc = subprocess.Popen(
@@ -172,11 +180,19 @@ def mobile_run_task(
cwd=project_root,
**proc_kwargs,
)
DeviceExecutionLock.transfer_reservation(
queue_ticket,
proc.pid,
description=f"MCP task: {task_desc[:120]}",
session_id=trace_id,
ingress="mcp",
)
# 5. Record the PID of the spawned subprocess
status_data = trace_store.read_status(trace_id)
if status_data:
status_data["pid"] = proc.pid
status_data["queue_ticket"] = queue_ticket
trace_store.write_status(trace_id, status_data)
trace_dir = trace_store.get_trace_dir(trace_id)
@@ -195,6 +211,7 @@ def mobile_run_task(
return response_dict
except Exception as e:
DeviceExecutionLock.cancel_reservation(queue_ticket)
trace_store.update_trace_status(
trace_id, "failed", error=f"Failed to spawn background runner: {e}"
)
@@ -0,0 +1,39 @@
from unittest.mock import patch
from apps.admin_console.database.connection import db_session
from apps.admin_console.database.repositories.session_repository import SessionRepository
def _insert_running_session(db_path, session_id, pid):
with db_session(db_path) as conn:
conn.execute(
"INSERT INTO sessions "
"(session_id, initial_goal, start_time, status, device_info, pid) "
"VALUES (?, ?, ?, ?, ?, ?)",
(session_id, session_id, 1.0, "running", "{}", pid),
)
conn.commit()
def test_startup_cleanup_keeps_live_cross_process_session(tmp_path):
db_path = tmp_path / "sessions.db"
repository = SessionRepository(db_path)
_insert_running_session(db_path, "live", 111)
_insert_running_session(db_path, "dead", 222)
with patch.object(repository, "process_is_alive", side_effect=lambda pid: pid == 111):
assert repository.cleanup_orphans_on_startup() == 1
assert repository.get_session_status("live") == "running"
assert repository.get_session_status("dead") == "failed"
def test_session_list_harvest_rechecks_worker_liveness(tmp_path):
db_path = tmp_path / "sessions.db"
repository = SessionRepository(db_path)
_insert_running_session(db_path, "external-live", 333)
with patch.object(repository, "process_is_alive", return_value=True):
assert repository.harvest_orphaned_sessions(["external-live"]) == 0
assert repository.get_session_status("external-live") == "running"
@@ -0,0 +1,51 @@
from apps.admin_console.core.state import ServerState
def test_startup_progress_is_ordered_and_upserted_by_stage():
state = ServerState()
state.record_startup_progress(
{
"session_id": "session-1",
"stage": "queued",
"message": "Task queued",
"timestamp": 1.0,
}
)
state.record_startup_progress(
{
"session_id": "session-1",
"stage": "device",
"message": "Checking device",
"timestamp": 2.0,
}
)
state.record_startup_progress(
{
"session_id": "session-1",
"stage": "device",
"message": "Device connected",
"timestamp": 3.0,
}
)
events = state.get_startup_progress("session-1")
assert [event["stage"] for event in events] == ["queued", "device"]
assert events[-1]["message"] == "Device connected"
def test_startup_progress_snapshot_is_not_mutable_by_callers():
state = ServerState()
state.record_startup_progress(
{
"session_id": "session-1",
"stage": "queued",
"message": "Task queued",
"timestamp": 1.0,
}
)
snapshot = state.get_startup_progress("session-1")
snapshot[0]["message"] = "changed"
assert state.get_startup_progress("session-1")[0]["message"] == "Task queued"
@@ -0,0 +1,76 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from unittest.mock import AsyncMock
from fastapi import HTTPException
import pytest
from apps.admin_console.routers import tasks
from apps.admin_console.schemas.task_schema import RunRequest
from artemis.core.diagnostics.schema import (
ProbeCategory,
ProbeResult,
ProbeStatus,
)
@pytest.mark.asyncio
async def test_run_task_rejects_locked_device(monkeypatch):
locked_probe = ProbeResult(
id="android_adb",
category=ProbeCategory.DEVICE,
title="Device / Emulator Connected",
status=ProbeStatus.WARN,
is_blocker=True,
summary="Device Locked",
description="Unlock the device.",
)
run_probe = AsyncMock(return_value=locked_probe)
enqueue_tasks = AsyncMock()
monkeypatch.setattr(
tasks.readiness_engine, "run_device_submission_probe", run_probe
)
monkeypatch.setattr(tasks.task_queue_service, "enqueue_tasks", enqueue_tasks)
with pytest.raises(HTTPException) as exc_info:
await tasks.run_task(RunRequest(goal="Open Settings"))
assert exc_info.value.status_code == 409
assert "locked" in exc_info.value.detail.lower()
enqueue_tasks.assert_not_awaited()
@pytest.mark.asyncio
async def test_run_task_enqueues_when_device_is_unlocked(monkeypatch):
unlocked_probe = ProbeResult(
id="android_adb",
category=ProbeCategory.DEVICE,
title="Device / Emulator Connected",
status=ProbeStatus.PASS,
is_blocker=True,
summary="Connected",
description="Ready.",
)
run_probe = AsyncMock(return_value=unlocked_probe)
enqueue_tasks = AsyncMock(return_value={"status": "started", "tasks": []})
monkeypatch.setattr(
tasks.readiness_engine, "run_device_submission_probe", run_probe
)
monkeypatch.setattr(tasks.task_queue_service, "enqueue_tasks", enqueue_tasks)
result = await tasks.run_task(RunRequest(goal="Open Settings"))
assert result["status"] == "started"
enqueue_tasks.assert_awaited_once()
@@ -22,7 +22,9 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from apps.admin_console.core.state import state
from apps.admin_console.routers.tasks import get_status
from apps.admin_console.services.task_queue_service import TaskQueueService, task_queue_service
from artemis.runtime.device_lock import DeviceLockOwner
@pytest.fixture(autouse=True)
@@ -39,13 +41,17 @@ def clean_state(tmp_path, monkeypatch):
state.current_goal = None
state.current_profile = None
state.active_session_id = None
state.active_connections.clear()
state.was_stopped_manually = False
state.cancelled_session_ids.clear()
if state.worker_task and not state.worker_task.done():
state.worker_task.cancel()
state.worker_task = None
yield
state.clear_queue()
state.queue_items.clear()
state.active_connections.clear()
state.cancelled_session_ids.clear()
if state.worker_task and not state.worker_task.done():
state.worker_task.cancel()
state.worker_task = None
@@ -135,11 +141,20 @@ async def test_stop_tasks_keep_remaining():
"apps.admin_console.services.task_queue_service.process_supervisor.terminate_tree"
) as mock_term,
patch.object(TaskQueueService, "ensure_worker_running"),
patch(
"apps.admin_console.services.task_queue_service.session_repo.update_session_status"
) as update_status,
patch(
"apps.admin_console.services.task_queue_service.session_repo.mark_all_running_cancelled"
) as mark_all,
):
stopped = task_queue_service.stop_tasks(clear_all=False)
assert stopped is True
mock_proc.kill.assert_called_once()
mock_term.assert_called_once_with(12345)
update_status.assert_called_once()
assert update_status.call_args.args[:2] == ("s1", "cancelled")
mark_all.assert_not_called()
@pytest.mark.asyncio
@@ -201,7 +216,7 @@ async def test_queue_worker_cmd_construction():
):
mock_repo.get_running_session_id.return_value = None
await task_queue_service.enqueue_tasks(
enqueue_result = await task_queue_service.enqueue_tasks(
["Test Goal with Outputter"],
profile="pro",
expected_output="Final summary",
@@ -225,6 +240,10 @@ async def test_queue_worker_cmd_construction():
assert "com.google.android.apps.maps" in cmd
assert "--app-path" in cmd
assert "/path/to/app.apk" in cmd
assert (
executed_kwargs[0]["env"]["ARTEMIS_DEVICE_QUEUE_TICKET"]
== (enqueue_result["tasks"][0]["queue_ticket"])
)
if sys.platform == "win32":
assert executed_kwargs[0]["creationflags"] == (
subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW
@@ -296,6 +315,136 @@ async def test_stop_tasks_dead_process_resets_state():
assert len(state.queue_items) == 0
def test_stop_tasks_terminates_external_global_owner_and_preserves_local_waiter():
external_owner = DeviceLockOwner(
pid=24680,
process_created_at=1234.5,
token="external-owner-token",
device_id="emulator-5554",
description="MCP task: inspect settings",
acquired_at="2026-08-24T00:00:00+00:00",
session_id="mcp-session",
ingress="mcp",
)
local_waiter = MagicMock(pid=13579, returncode=None)
state.current_process = local_waiter
state.queue_items = [
{
"session_id": "frontend-waiter",
"goal": "Run after MCP",
"status": "running",
"queue_ticket": "frontend-ticket",
}
]
state.active_session_id = "mcp-session"
state.active_connections["mcp-session"] = {"pid": 24680}
with (
patch.object(
TaskQueueService,
"ensure_worker_running",
),
patch(
"apps.admin_console.services.task_queue_service.DeviceExecutionLock.get_active_owner",
return_value=external_owner,
),
patch(
"apps.admin_console.services.task_queue_service.DeviceExecutionLock.is_active_owner",
return_value=True,
),
patch(
"apps.admin_console.services.task_queue_service.DeviceExecutionLock.cleanup_stale_locks"
),
patch(
"apps.admin_console.services.task_queue_service.process_supervisor.terminate_tree_verified",
return_value=True,
) as terminate_verified,
patch(
"apps.admin_console.services.task_queue_service.session_repo.update_session_status"
) as update_status,
patch(
"mcp_server.utils.trace_store.update_trace_status"
) as update_trace_status,
):
assert task_queue_service.stop_tasks(clear_all=False) is True
terminate_verified.assert_called_once_with(24680, 1234.5)
local_waiter.kill.assert_not_called()
assert state.current_process is local_waiter
assert state.queue_items[0]["session_id"] == "frontend-waiter"
assert state.was_stopped_manually is False
assert "frontend-waiter" not in state.cancelled_session_ids
assert "mcp-session" not in state.active_connections
update_status.assert_called_once()
assert update_status.call_args.args[:2] == ("mcp-session", "cancelled")
update_trace_status.assert_called_once_with(
"mcp-session",
"cancelled",
error="Task stopped from the Artemis frontend.",
)
def test_stop_tasks_does_not_kill_stale_reused_pid():
stale_owner = DeviceLockOwner(
pid=24680,
process_created_at=1234.5,
token="stale-token",
device_id="emulator-5554",
description="CLI task",
acquired_at="2026-08-24T00:00:00+00:00",
session_id="cli-session",
ingress="sdk",
)
with (
patch(
"apps.admin_console.services.task_queue_service.DeviceExecutionLock.get_active_owner",
return_value=stale_owner,
),
patch(
"apps.admin_console.services.task_queue_service.DeviceExecutionLock.is_active_owner",
return_value=False,
),
patch(
"apps.admin_console.services.task_queue_service.process_supervisor.terminate_tree_verified"
) as terminate_verified,
):
assert task_queue_service.stop_tasks(clear_all=False) is False
terminate_verified.assert_not_called()
@pytest.mark.asyncio
async def test_status_reports_external_global_owner_without_ipc_connection():
external_owner = DeviceLockOwner(
pid=24680,
process_created_at=1234.5,
token="external-owner-token",
device_id="emulator-5554",
description="CLI task: inspect settings",
acquired_at="2026-08-24T00:00:00+00:00",
session_id="cli-session",
ingress="cli",
)
with (
patch.object(TaskQueueService, "ensure_worker_running"),
patch(
"apps.admin_console.routers.tasks.DeviceExecutionLock.get_active_owner",
return_value=external_owner,
),
patch("apps.admin_console.routers.tasks.session_repo") as repo,
patch("apps.admin_console.routers.tasks.model_service") as models,
):
repo.get_latest_session.return_value = None
repo.get_session_by_id.return_value = None
models.get_active_model_info.return_value = None
result = await get_status()
assert result["status"] == "running"
assert result["session_id"] == "cli-session"
assert result["pid"] == 24680
assert result["goal"] == "CLI task: inspect settings"
@pytest.mark.asyncio
async def test_cancel_task_triggers_next_pending_task():
executed_goals = []
+53
View File
@@ -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
+79
View File
@@ -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."""
+32
View File
@@ -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 {
+128
View File
@@ -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
+166
View File
@@ -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"]
+123 -4
View File
@@ -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()
+27
View File
@@ -0,0 +1,27 @@
from unittest.mock import MagicMock, patch
from artemis.runtime.supervisor import ProcessSupervisor
def test_verified_termination_rejects_reused_pid():
process = MagicMock()
process.create_time.return_value = 200.0
with (
patch("psutil.Process", return_value=process),
patch.object(ProcessSupervisor, "terminate_tree") as terminate_tree,
):
assert ProcessSupervisor.terminate_tree_verified(1234, 100.0) is False
terminate_tree.assert_not_called()
def test_verified_termination_stops_matching_process_tree():
process = MagicMock()
process.create_time.return_value = 100.0
with (
patch("psutil.Process", return_value=process),
patch.object(ProcessSupervisor, "terminate_tree", return_value=True) as terminate_tree,
):
assert ProcessSupervisor.terminate_tree_verified(1234, 100.0) is True
terminate_tree.assert_called_once_with(1234, timeout_seconds=3.0)
@@ -12,11 +12,14 @@
# See the License for the specific language governing permissions and
# limitations under the License.
from unittest.mock import AsyncMock, MagicMock
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from artemis.sdk.agent import Agent
from artemis.context import DeviceContext, DevicePlatform
from artemis.runtime.device_lock import DeviceBusyError
from artemis.sdk.types.exceptions import AgentError
@pytest.mark.asyncio
@@ -30,3 +33,83 @@ async def test_trace_finalization_failure_does_not_escape():
await agent._finalize_tracing_safely(task=task, context=context)
agent._finalize_tracing.assert_awaited_once_with(task=task, context=context)
@pytest.mark.asyncio
async def test_agent_clean_disconnects_the_shared_ui_client():
agent = object.__new__(Agent)
agent._initialized = True
agent._ui_adb_client = MagicMock()
await agent.clean()
agent._ui_adb_client.disconnect.assert_called_once_with()
assert agent._initialized is False
@pytest.mark.asyncio
async def test_device_context_uses_adb_size_without_starting_ui_client():
agent = object.__new__(Agent)
agent._adb_client = MagicMock()
agent._adb_client.device.return_value.window_size.return_value = (1080, 2424)
agent._ui_adb_client = MagicMock()
from artemis.context import DevicePlatform
context = await agent._get_device_context("device-123", DevicePlatform.ANDROID)
assert context.device_width == 1080
assert context.device_height == 2424
agent._ui_adb_client.get_screen_data.assert_not_called()
@pytest.mark.asyncio
async def test_task_that_never_acquires_queue_does_not_create_trace_session():
agent = Agent()
agent._initialized = True
agent._device_context = DeviceContext(
host_platform="WINDOWS",
mobile_platform=DevicePlatform.ANDROID,
device_id="device-123",
device_width=1080,
device_height=2424,
)
agent._adb_client = MagicMock()
agent._ui_adb_client = MagicMock()
agent._prepare_tracing = MagicMock()
with patch(
"artemis.sdk.agent.DeviceExecutionLock.acquire",
side_effect=DeviceBusyError("queue cancelled"),
):
with pytest.raises(DeviceBusyError, match="queue cancelled"):
await agent.run_task(goal="queued task", profile="flash")
agent._prepare_tracing.assert_not_called()
@pytest.mark.asyncio
async def test_secure_keyguard_is_rejected_without_guessing_credentials():
agent = object.__new__(Agent)
agent._device_context = MagicMock(device_id="device-123")
agent._adb_client = MagicMock()
agent._adb_client.device.return_value.shell.return_value = (
'User "Owner" (id=0): deviceLocked=1\n'
'User "Work" (id=10): deviceLocked=1\n'
)
with pytest.raises(AgentError, match="Unlock the device manually"):
await agent._ensure_device_unlocked()
@pytest.mark.asyncio
async def test_locked_work_profile_does_not_block_unlocked_device_owner():
agent = object.__new__(Agent)
agent._device_context = MagicMock(device_id="device-123")
agent._adb_client = MagicMock()
agent._adb_client.device.return_value.shell.return_value = (
'User "Owner" (id=0): deviceLocked=0\n'
'User "Work" (id=10): deviceLocked=1\n'
)
await agent._ensure_device_unlocked()
+31
View File
@@ -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"
+34 -123
View File
@@ -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
+26 -1
View File
@@ -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):