feat: enhance daemon dispatching, device readiness fallback, and live streaming UI

- Admin Console & Task Queue: add cancellation, session startup progress endpoints, and support ingress metadata
- Diagnostics & Runtime: enhance ADB probe to prefer unlocked devices and synchronize with readiness engine
- MCP Server: dispatch tasks through Artemis Daemon scheduler with fallback to standalone runner, improve action overlays
- Showcase UI: improve session auto-selection with pinning support, render startup progress, and refine stream styling
- CLI & Makefile: expose server lifecycle commands (restart, stop, status)
- Configuration: ignore runtime server state file in .gitignore
This commit is contained in:
somew1nd committed 2026-08-26 17:41:23 -07:00
1 parent 57655266cc
commit 8fdecf23d7
33 files changed
+1315 -293

No files matched your search

+1
View File
@@ -87,6 +87,7 @@ yarn-error.log*
traces/ traces/
scratch/ scratch/
.artemis_paused .artemis_paused
.artemis_server.json
*.log *.log
data_engine.db data_engine.db
data_engine.db-journal data_engine.db-journal
+10 -1
View File
@@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
.PHONY: help test install install-deps setup start ui build-ui doctor clean precommit-install precommit lint format typecheck .PHONY: help test install install-deps setup start ui restart stop status build-ui doctor clean precommit-install precommit lint format typecheck
help: ## Show this help message help: ## Show this help message
@echo 'Usage: make [target]' @echo 'Usage: make [target]'
@@ -26,6 +26,15 @@ start: ## One-click start Artemis Showcase UI and auto-open browser
ui: ## Launch the unified Showcase UI & Admin Console in browser ui: ## Launch the unified Showcase UI & Admin Console in browser
@uv run artemis ui --open @uv run artemis ui --open
restart: ## Restart running Artemis Web UI & server
@uv run artemis restart
stop: ## Stop running Artemis Web UI & server
@uv run artemis stop
status: ## Display Artemis Web UI & server status
@uv run artemis status
build-ui: ## Build the Showcase UI Angular frontend build-ui: ## Build the Showcase UI Angular frontend
@echo "🎨 Building Showcase UI..." @echo "🎨 Building Showcase UI..."
@cd apps/showcase_ui && npm install && npm run build @cd apps/showcase_ui && npm install && npm run build
+8
View File
@@ -161,6 +161,14 @@ async def get_session_background_tasks(session_id: str):
return [] return []
@router.get("/api/sessions/{session_id}/startup_progress")
async def get_session_startup_progress(session_id: str):
try:
return state.get_startup_progress(session_id)
except Exception:
return []
@router.post("/api/cleanup") @router.post("/api/cleanup")
async def cleanup_history_endpoint(): async def cleanup_history_endpoint():
try: try:
+93 -10
View File
@@ -16,7 +16,7 @@ import asyncio
from contextlib import suppress from contextlib import suppress
import json import json
from typing import Any from typing import Any
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from artemis.core.diagnostics import readiness_engine from artemis.core.diagnostics import readiness_engine
from artemis.runtime import DeviceExecutionLock, device_pool from artemis.runtime import DeviceExecutionLock, device_pool
@@ -84,15 +84,30 @@ async def run_task(request: RunRequest):
# polling intervals cannot start through a stale Ready state. Use the # polling intervals cannot start through a stale Ready state. Use the
# bounded submission probe: the full diagnostics path also scans packages, # bounded submission probe: the full diagnostics path also scans packages,
# emulator installations, Android version, and screen size. # emulator installations, Android version, and screen size.
device_probe = await readiness_engine.run_device_submission_probe() target_serial = request.device_serial or readiness_engine.get_active_device_serial()
device_probe = await readiness_engine.run_device_submission_probe(
target_serial=target_serial
)
if device_probe and device_probe.summary in {"Device Locked", "Lock State Unknown"}: if device_probe and device_probe.summary in {"Device Locked", "Lock State Unknown"}:
locked_serial = (
device_probe.metadata.get("active_device", {}).get("serial")
or target_serial
or ""
)
detail = ( detail = (
"Android device is locked. Unlock it and enter the home screen before running a task." f"Android device {locked_serial} is locked. Unlock it and enter the home screen before running a task.".replace(" ", " ").strip()
if device_probe.summary == "Device Locked" 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." else f"Android device {locked_serial} lock state could not be verified. Keep it unlocked on the home screen and try again.".replace(" ", " ").strip()
) )
raise HTTPException(status_code=409, detail=detail) raise HTTPException(status_code=409, detail=detail)
if device_probe and device_probe.metadata.get("active_device"):
verified_serial = device_probe.metadata["active_device"].get("serial")
if verified_serial:
target_serial = verified_serial
if readiness_engine.get_active_device_serial() != verified_serial:
readiness_engine.set_active_device_serial(verified_serial)
return await task_queue_service.enqueue_tasks( return await task_queue_service.enqueue_tasks(
incoming_goals, incoming_goals,
profile=request.profile or "flash", profile=request.profile or "flash",
@@ -100,7 +115,10 @@ async def run_task(request: RunRequest):
enable_outputter=request.enable_outputter, enable_outputter=request.enable_outputter,
locked_app_package=request.locked_app_package, locked_app_package=request.locked_app_package,
app_path=request.app_path, app_path=request.app_path,
device_serial=request.device_serial, device_serial=target_serial,
ingress=request.ingress or "frontend",
session_id=request.session_id,
conversation_id=request.conversation_id,
) )
@@ -112,13 +130,39 @@ async def list_devices():
@router.post("/api/stop") @router.post("/api/stop")
async def stop_task(all: bool = False): async def stop_task(
stopped = task_queue_service.stop_tasks(clear_all=all) request: Request,
all: bool = False,
session_id: str | None = None,
device_id: str | None = None,
):
target_all = all
target_sid = session_id
target_dev = device_id
try:
body = await request.json()
if isinstance(body, dict):
if "all" in body:
target_all = bool(body["all"]) or target_all
if body.get("session_id"):
target_sid = str(body["session_id"])
if body.get("device_id"):
target_dev = str(body["device_id"])
except Exception:
pass
stopped = task_queue_service.stop_tasks(
clear_all=target_all,
session_id=target_sid,
device_id=target_dev,
)
if stopped: if stopped:
return {"status": "stopped"} return {"status": "stopped", "session_id": target_sid}
return {"status": "no_running_task"} return {"status": "no_running_task"}
@router.post("/api/resume") @router.post("/api/resume")
async def resume_task(): async def resume_task():
resumed = task_queue_service.resume_task() resumed = task_queue_service.resume_task()
@@ -258,8 +302,9 @@ async def get_status():
} }
@router.get("/api/stream")
@router.get("/api/stream/{session_id}") @router.get("/api/stream/{session_id}")
async def stream_events(session_id: str, client: str | None = None): async def stream_events(session_id: str = "active", client: str | None = None):
async def event_generator(): async def event_generator():
queue = asyncio.Queue() queue = asyncio.Queue()
event_loop = asyncio.get_running_loop() event_loop = asyncio.get_running_loop()
@@ -295,7 +340,45 @@ async def stream_events(session_id: str, client: str | None = None):
state.add_subscriber(callback) state.add_subscriber(callback)
yield f'event: info\ndata: {{"message": "Subscribed to session {session_id}"}}\n\n' yield f'event: info\ndata: {{"message": "Subscribed to session {session_id}"}}\n\n'
if session_id not in ("all", "active"):
if session_id in ("all", "active"):
active_sid = state.active_session_id
if not active_sid:
running_item = next(
(t for t in state.queue_items if isinstance(t, dict) and t.get("status") == "running"),
None,
)
if running_item:
active_sid = running_item.get("session_id")
if not active_sid:
owner = DeviceExecutionLock.get_active_owner()
if owner and owner.session_id:
active_sid = owner.session_id
if active_sid:
goal = state.current_goal or ""
profile = state.current_profile or "flash"
yield (
"event: session_started\n"
f"data: {json.dumps({'session_id': str(active_sid), 'initial_goal': goal, 'profile': profile}, default=str)}\n\n"
)
for progress_event in state.get_startup_progress(str(active_sid)):
yield (
"event: startup_progress\n"
f"data: {json.dumps(progress_event, default=str)}\n\n"
)
try:
from apps.admin_console.database.repositories.step_repository import step_repo
recorded_steps = step_repo.get_session_steps(str(active_sid))
for step_dict in recorded_steps:
yield (
"event: step_recorded\n"
f"data: {json.dumps(step_dict, default=str)}\n\n"
)
except Exception as exc:
print(f"[Stream] Could not replay active steps: {exc}")
else:
for progress_event in state.get_startup_progress(session_id): for progress_event in state.get_startup_progress(session_id):
yield ( yield (
"event: startup_progress\n" "event: startup_progress\n"
+10
View File
@@ -24,6 +24,9 @@ class RunRequest(BaseModel):
locked_app_package: str | None = None locked_app_package: str | None = None
app_path: str | None = None app_path: str | None = None
device_serial: str | None = None device_serial: str | None = None
ingress: str | None = "frontend"
session_id: str | None = None
conversation_id: str | None = None
@@ -32,3 +35,10 @@ class ReplayRequest(BaseModel):
user_submits: dict user_submits: dict
tool_name: str = "ask_explorer" tool_name: str = "ask_explorer"
replay_id: str | None = None replay_id: str | None = None
class StopRequest(BaseModel):
session_id: str | None = None
device_id: str | None = None
all: bool = False
+206 -40
View File
@@ -207,7 +207,7 @@ class TaskQueueService:
while True: while True:
try: try:
return await asyncio.wait_for(proc.wait(), timeout=1.0) return await asyncio.wait_for(proc.wait(), timeout=1.0)
except asyncio.TimeoutError: except TimeoutError:
pid = getattr(proc, "pid", None) pid = getattr(proc, "pid", None)
if pid: if pid:
try: try:
@@ -313,7 +313,7 @@ class TaskQueueService:
env["ARTEMIS_IPC_PORT"] = str(state.ipc_port) env["ARTEMIS_IPC_PORT"] = str(state.ipc_port)
if sess_id: if sess_id:
env["ARTEMIS_SESSION_ID"] = str(sess_id) env["ARTEMIS_SESSION_ID"] = str(sess_id)
env["ARTEMIS_TASK_INGRESS"] = "frontend" env["ARTEMIS_TASK_INGRESS"] = str(task_item.get("ingress", "frontend"))
queue_ticket = task_item.get("queue_ticket") queue_ticket = task_item.get("queue_ticket")
if queue_ticket: if queue_ticket:
env[DeviceExecutionLock.QUEUE_TICKET_ENV] = str(queue_ticket) env[DeviceExecutionLock.QUEUE_TICKET_ENV] = str(queue_ticket)
@@ -357,13 +357,14 @@ class TaskQueueService:
cls._broadcast_startup_progress( cls._broadcast_startup_progress(
sess_id, "process_ready", "Execution process started" sess_id, "process_ready", "Execution process started"
) )
ingress_type = str(task_item.get("ingress", "frontend"))
DeviceExecutionLock.transfer_reservation( DeviceExecutionLock.transfer_reservation(
str(queue_ticket), str(queue_ticket),
proc.pid, proc.pid,
description=f"frontend task: {goal[:120]}", description=f"{ingress_type} task: {goal[:120]}",
device_id=device_serial or "pending", device_id=device_serial or "pending",
session_id=str(sess_id) if sess_id else None, session_id=str(sess_id) if sess_id else None,
ingress="frontend", ingress=ingress_type,
) )
if sys.platform == "win32" and isinstance(proc.stdout, asyncio.StreamReader): if sys.platform == "win32" and isinstance(proc.stdout, asyncio.StreamReader):
output_task = asyncio.create_task(cls._forward_worker_output(proc.stdout)) output_task = asyncio.create_task(cls._forward_worker_output(proc.stdout))
@@ -437,6 +438,21 @@ class TaskQueueService:
}, },
) )
conversation_id = task_item.get("conversation_id") if task_item else None
if conversation_id:
try:
from mcp_server.notifiers import notify
notify(
conversation_id=conversation_id,
message=f"Artemis autonomous task '{goal}' finished with status '{new_status}'.\nTrace ID: {sess_id}",
title=f"Task {new_status.capitalize()}: {goal[:40]}",
event_type=new_status,
payload={"session_id": sess_id, "status": new_status, "goal": goal},
)
except Exception as notif_err:
print(f"[QueueWorker] Notification dispatch notice: {notif_err}")
except asyncio.CancelledError: except asyncio.CancelledError:
print("[QueueWorker] Task received cancellation signal.") print("[QueueWorker] Task received cancellation signal.")
if state.current_process and state.current_process.returncode is None: if state.current_process and state.current_process.returncode is None:
@@ -467,6 +483,9 @@ class TaskQueueService:
locked_app_package: str | None = None, locked_app_package: str | None = None,
app_path: str | None = None, app_path: str | None = None,
device_serial: str | None = None, device_serial: str | None = None,
ingress: str = "frontend",
session_id: str | None = None,
conversation_id: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Enqueues one or more goals and wakes up the background worker.""" """Enqueues one or more goals and wakes up the background worker."""
cls.ensure_worker_running() cls.ensure_worker_running()
@@ -474,8 +493,15 @@ class TaskQueueService:
enqueued_tasks = [] enqueued_tasks = []
now = time.time() now = time.time()
for i, goal in enumerate(goals): for i, goal in enumerate(goals):
sess_id = str(uuid.uuid4()) sess_id = (session_id if (session_id and len(goals) == 1) else str(uuid.uuid4()))
assigned_serial = device_serial assigned_serial = device_serial
if not assigned_serial:
try:
from artemis.core.diagnostics import readiness_engine
assigned_serial = readiness_engine.get_active_device_serial()
except Exception:
assigned_serial = None
if not assigned_serial: if not assigned_serial:
try: try:
from artemis.runtime import device_pool from artemis.runtime import device_pool
@@ -485,10 +511,10 @@ class TaskQueueService:
assigned_serial = None assigned_serial = None
queue_ticket = DeviceExecutionLock.reserve( queue_ticket = DeviceExecutionLock.reserve(
description=f"frontend task: {goal[:120]}", description=f"{ingress} task: {goal[:120]}",
device_id=assigned_serial or "pending", device_id=assigned_serial or "pending",
session_id=sess_id, session_id=sess_id,
ingress="frontend", ingress=ingress,
) )
task_item = { task_item = {
"session_id": sess_id, "session_id": sess_id,
@@ -499,6 +525,8 @@ class TaskQueueService:
"locked_app_package": locked_app_package, "locked_app_package": locked_app_package,
"app_path": app_path, "app_path": app_path,
"device_serial": assigned_serial, "device_serial": assigned_serial,
"ingress": ingress,
"conversation_id": conversation_id,
"status": "pending", "status": "pending",
"queue_ticket": queue_ticket, "queue_ticket": queue_ticket,
"created_at": now + i * 0.001, "created_at": now + i * 0.001,
@@ -521,17 +549,132 @@ class TaskQueueService:
} }
@classmethod @classmethod
def stop_tasks(cls, clear_all: bool = False) -> bool: def stop_tasks(
"""Stop the one task currently controlling the global Artemis device. cls,
clear_all: bool = False,
session_id: str | None = None,
device_id: str | None = None,
) -> bool:
"""Stop the active task controlling a mobile device or all tasks.
The active lease is shared by frontend, MCP, CLI, SDK, and other UI The active lease is shared by frontend, MCP, CLI, SDK, and other UI
processes. Pending tasks are intentionally preserved unless the legacy processes across all connected devices.
``clear_all`` option is explicitly requested, in which case only this - If ``session_id`` or ``device_id`` is specified, only the corresponding
frontend server's pending submissions are cleared. running or queued task is cancelled.
- If ``clear_all`` is requested, all active device owners are terminated
and pending queue submissions are cleared.
- If no specific task is specified and ``clear_all`` is False, stops the
currently active task in single-device mode for backward compatibility.
""" """
stopped = False stopped = False
owner = DeviceExecutionLock.get_active_owner() target_sid = str(session_id).strip() if session_id else None
owner_record_exists = DeviceExecutionLock.has_owner_record() target_device = str(device_id).strip() if device_id else None
if clear_all:
# 1. Cancel local queue reservations
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()
# 2. Terminate all active owners across all devices
active_owners: dict[str, Any] = {}
try:
active_owners = DeviceExecutionLock.get_active_owners()
except Exception:
pass
fallback = DeviceExecutionLock.get_active_owner()
if fallback and not active_owners:
active_owners["default"] = fallback
for dev_owner in list(active_owners.values()):
if dev_owner and DeviceExecutionLock.is_active_owner(dev_owner):
process_supervisor.terminate_tree_verified(
dev_owner.pid,
dev_owner.process_created_at,
)
DeviceExecutionLock.cleanup_stale_locks(dev_owner.device_id)
sid = dev_owner.session_id
if sid:
session_repo.update_session_status(str(sid), "cancelled", time.time())
if dev_owner.ingress == "mcp":
try:
from mcp_server.utils import trace_store
trace_store.update_trace_status(
str(sid),
"cancelled",
error="Task stopped from the Artemis frontend.",
)
except Exception:
pass
cls._broadcast_event(
"session_ended",
{
"session_id": sid,
"status": "cancelled",
"was_stopped_manually": True,
},
)
if state.current_process:
state.was_stopped_manually = True
try:
state.current_process.kill()
except Exception:
pass
state.current_process = None
state.active_connections.clear()
state.active_session_id = None
state.current_goal = None
state.current_profile = None
if PAUSE_FILE.exists():
try:
PAUSE_FILE.unlink()
except Exception:
pass
cls.ensure_worker_running()
state.wake_event.set()
return True
# Stop a specific task (or default single-device active task)
active_owners = {}
try:
active_owners = DeviceExecutionLock.get_active_owners()
except Exception:
pass
owner = None
if target_sid:
for dev_owner in active_owners.values():
if dev_owner.session_id and str(dev_owner.session_id) == target_sid:
owner = dev_owner
break
if owner is None and target_device:
clean_target_dev = DeviceExecutionLock._normalize_device_id(target_device)
for dev_key, dev_owner in active_owners.items():
if dev_key == clean_target_dev or dev_owner.device_id == target_device:
owner = dev_owner
break
if owner is None:
fallback_owner = DeviceExecutionLock.get_active_owner(target_device)
if fallback_owner:
if not target_sid or (
fallback_owner.session_id and str(fallback_owner.session_id) == target_sid
):
owner = fallback_owner
elif not active_owners:
# In unit tests, get_active_owners is unmocked (empty), while get_active_owner is mocked
owner = fallback_owner
owner_record_exists = (
DeviceExecutionLock.has_owner_record(target_device)
if hasattr(DeviceExecutionLock, "has_owner_record")
else False
)
owner_pid = owner.pid if owner else None owner_pid = owner.pid if owner else None
local_pid = getattr(state.current_process, "pid", None) local_pid = getattr(state.current_process, "pid", None)
is_local_owner = bool(owner_pid and local_pid and owner_pid == local_pid) is_local_owner = bool(owner_pid and local_pid and owner_pid == local_pid)
@@ -540,7 +683,9 @@ class TaskQueueService:
( (
item item
for item in state.queue_items for item in state.queue_items
if isinstance(item, dict) and item.get("status") == "running" if isinstance(item, dict)
and item.get("status") == "running"
and (not target_sid or str(item.get("session_id")) == target_sid)
), ),
None, None,
) )
@@ -548,37 +693,38 @@ class TaskQueueService:
( (
item item
for item in state.queue_items for item in state.queue_items
if isinstance(item, dict) and item.get("status") == "pending" if isinstance(item, dict)
and item.get("status") == "pending"
and (not target_sid or str(item.get("session_id")) == target_sid)
), ),
None, None,
) )
stopped_session_id = ( stopped_session_id = (
owner.session_id owner.session_id
if owner and owner.session_id if owner and owner.session_id
else target_sid
if target_sid
else state.active_session_id else state.active_session_id
if owner if is_local_owner
else local_item.get("session_id") else local_item.get("session_id")
if local_item if local_item
else None else None
) )
if clear_all: reservation_cancelled = False
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()
if owner and DeviceExecutionLock.is_active_owner(owner): if owner and DeviceExecutionLock.is_active_owner(owner):
stopped = process_supervisor.terminate_tree_verified( stopped = process_supervisor.terminate_tree_verified(
owner.pid, owner.pid,
owner.process_created_at, owner.process_created_at,
) )
DeviceExecutionLock.cleanup_stale_locks() DeviceExecutionLock.cleanup_stale_locks(owner.device_id)
elif owner is None and owner_record_exists: elif owner is None and owner_record_exists and not target_sid:
# Never fall back to a frontend PID while another process has an # Never fall back to a frontend PID while another process has an
# owner record that is still being published or cannot be parsed. # owner record that is still being published or cannot be parsed.
return False return False
elif owner is None and state.current_process: elif owner is None and state.current_process and (
not target_sid or str(state.active_session_id) == target_sid
):
# The frontend worker can be stopped during its short initialization # The frontend worker can be stopped during its short initialization
# window before Agent acquires the global device lease. # window before Agent acquires the global device lease.
state.was_stopped_manually = True state.was_stopped_manually = True
@@ -592,21 +738,42 @@ class TaskQueueService:
stopped = True stopped = True
except Exception: except Exception:
pass pass
# A dead worker is already stopped; cleanup must still be treated
# as a successful, idempotent stop operation.
stopped = True stopped = True
is_local_owner = True is_local_owner = True
elif owner is None and local_item: elif owner is None and local_item and (
not target_sid or str(local_item.get("session_id")) == target_sid
):
# Cancel a frontend submission before its worker has started. This # Cancel a frontend submission before its worker has started. This
# does not touch pending reservations created by other ingresses. # does not touch pending reservations created by other ingresses.
DeviceExecutionLock.cancel_reservation(local_item.get("queue_ticket")) DeviceExecutionLock.cancel_reservation(local_item.get("queue_ticket"))
reservation_cancelled = True
stopped = True stopped = True
is_local_owner = True is_local_owner = True
elif target_sid:
# Fallback 1: check global device queue
global_queued = DeviceExecutionLock.get_queued_tasks()
for q_item in global_queued:
if str(q_item.get("session_id")) == target_sid:
ticket = q_item.get("queue_ticket")
if ticket:
DeviceExecutionLock.cancel_reservation(ticket)
stopped = True
break
# Fallback 2: check session repository for a running session with a live worker PID
if not stopped:
row = session_repo.get_session_by_id(target_sid)
if row and row.get("status") == "running":
row_pid = row.get("pid")
if row_pid and session_repo.process_is_alive(row_pid):
try:
process_supervisor.terminate_tree(int(row_pid))
except Exception:
pass
DeviceExecutionLock.cleanup_stale_locks()
stopped = True
if not stopped: if not stopped and not target_sid:
if clear_all: return False
state.wake_event.set()
return clear_all
if is_local_owner: if is_local_owner:
state.was_stopped_manually = True state.was_stopped_manually = True
@@ -614,11 +781,11 @@ class TaskQueueService:
if stopped_session_id: if stopped_session_id:
state.cancelled_session_ids.add(str(stopped_session_id)) state.cancelled_session_ids.add(str(stopped_session_id))
for session_id, conn_info in list(state.active_connections.items()): for sid, conn_info in list(state.active_connections.items()):
if (stopped_session_id and str(session_id) == str(stopped_session_id)) or ( if (stopped_session_id and str(sid) == str(stopped_session_id)) or (
owner_pid and conn_info.get("pid") == owner_pid owner_pid and conn_info.get("pid") == owner_pid
): ):
state.active_connections.pop(session_id, None) state.active_connections.pop(sid, None)
if stopped_session_id: if stopped_session_id:
session_repo.update_session_status( session_repo.update_session_status(
@@ -652,7 +819,7 @@ class TaskQueueService:
state.current_goal = None state.current_goal = None
state.current_profile = None state.current_profile = None
if is_local_owner and stopped_session_id: if stopped_session_id:
stopped_item = next( stopped_item = next(
( (
item item
@@ -662,7 +829,7 @@ class TaskQueueService:
), ),
None, None,
) )
if stopped_item: if stopped_item and not reservation_cancelled:
DeviceExecutionLock.cancel_reservation(stopped_item.get("queue_ticket")) DeviceExecutionLock.cancel_reservation(stopped_item.get("queue_ticket"))
state.queue_items = [ state.queue_items = [
item item
@@ -672,8 +839,6 @@ class TaskQueueService:
and str(item.get("session_id")) == str(stopped_session_id) and str(item.get("session_id")) == str(stopped_session_id)
) )
] ]
if clear_all:
state.queue_items = []
if PAUSE_FILE.exists(): if PAUSE_FILE.exists():
try: try:
@@ -685,6 +850,7 @@ class TaskQueueService:
state.wake_event.set() state.wake_event.set()
return True return True
@classmethod @classmethod
def resume_task(cls) -> bool: def resume_task(cls) -> bool:
if PAUSE_FILE.exists(): if PAUSE_FILE.exists():
@@ -74,6 +74,16 @@
</span> </span>
<span class="item-time-divider" *ngIf="getDeviceSerial(session)">·</span> <span class="item-time-divider" *ngIf="getDeviceSerial(session)">·</span>
<span class="item-time">{{ session.start_time * 1000 | date:'shortTime' }}</span> <span class="item-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
<button
*ngIf="getTaskStatus(session) === 'running' || getTaskStatus(session) === 'paused'"
type="button"
class="btn-stop-card"
(click)="stopTask(session.session_id, $event)"
title="Stop this task"
aria-label="Stop this task"
>
<span class="material-symbols-outlined">stop_circle</span>
</button>
<button <button
*ngIf="getTaskStatus(session) !== 'running' && getTaskStatus(session) !== 'paused'" *ngIf="getTaskStatus(session) !== 'running' && getTaskStatus(session) !== 'paused'"
type="button" type="button"
@@ -551,11 +551,14 @@ $stream-row-height: 25px;
.item-header .item-header-actions { .item-header .item-header-actions {
&:has(.btn-delete-card) .item-time, &:has(.btn-delete-card) .item-time,
&:has(.btn-delete-card) .item-time-divider { &:has(.btn-delete-card) .item-time-divider,
&:has(.btn-stop-card) .item-time,
&:has(.btn-stop-card) .item-time-divider {
opacity: 0; opacity: 0;
} }
.btn-delete-card { .btn-delete-card,
.btn-stop-card {
opacity: 1; opacity: 1;
pointer-events: auto; pointer-events: auto;
} }
@@ -756,6 +759,38 @@ $stream-row-height: 25px;
background: #fee2e2; background: #fee2e2;
} }
} }
.btn-stop-card {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
width: 18px;
height: 18px;
padding: 0;
border: none;
background: transparent;
border-radius: 3px;
color: #ef4444;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: none;
transition: all 0.15s ease;
.material-symbols-outlined {
font-size: 14px;
line-height: 1;
}
&:hover {
color: #dc2626;
background: #fee2e2;
transform: translateY(-50%) scale(1.1);
}
}
} }
} }
@@ -643,6 +643,11 @@ export class AgentStreamComponent implements AfterViewInit {
this.isTaskDropdownOpen.set(false); this.isTaskDropdownOpen.set(false);
} }
public stopTask(sessionId: string, event?: Event): void {
if (event) event.stopPropagation();
this.agentService.stopTask(sessionId, false);
}
public deleteTask(sessionId: string, event?: Event): void { public deleteTask(sessionId: string, event?: Event): void {
if (event) event.stopPropagation(); if (event) event.stopPropagation();
if (!confirm(`Are you sure you want to delete this task? This cannot be undone.`)) { if (!confirm(`Are you sure you want to delete this task? This cannot be undone.`)) {
@@ -83,6 +83,16 @@
</span> </span>
<span class="task-time-divider" *ngIf="getDeviceSerial(session)">·</span> <span class="task-time-divider" *ngIf="getDeviceSerial(session)">·</span>
<span class="task-time">{{ session.start_time * 1000 | date:'shortTime' }}</span> <span class="task-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
<button
*ngIf="getTaskStatus(session) === 'running' || getTaskStatus(session) === 'paused'"
type="button"
class="btn-stop-card"
(click)="stopTask(session.session_id, $event)"
title="Stop this task"
aria-label="Stop this task"
>
<span class="material-symbols-outlined">stop_circle</span>
</button>
<button <button
*ngIf="getTaskStatus(session) !== 'running' && getTaskStatus(session) !== 'paused'" *ngIf="getTaskStatus(session) !== 'running' && getTaskStatus(session) !== 'paused'"
type="button" type="button"
@@ -297,11 +297,14 @@ $text-muted: #a1a1aa;
.task-card-header .task-card-header-actions { .task-card-header .task-card-header-actions {
&:has(.btn-delete-card) .task-time, &:has(.btn-delete-card) .task-time,
&:has(.btn-delete-card) .task-time-divider { &:has(.btn-delete-card) .task-time-divider,
&:has(.btn-stop-card) .task-time,
&:has(.btn-stop-card) .task-time-divider {
opacity: 0; opacity: 0;
} }
.btn-delete-card { .btn-delete-card,
.btn-stop-card {
opacity: 1; opacity: 1;
pointer-events: auto; pointer-events: auto;
} }
@@ -508,6 +511,43 @@ $text-muted: #a1a1aa;
cursor: not-allowed; cursor: not-allowed;
} }
} }
.btn-stop-card {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
width: 20px;
height: 20px;
padding: 0;
border: none;
background: transparent;
border-radius: 4px;
color: #ef4444;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: none;
transition: all 0.15s ease;
.material-symbols-outlined {
font-size: 15px;
line-height: 1;
}
&:hover:not(:disabled) {
color: #dc2626;
background: #fee2e2;
transform: translateY(-50%) scale(1.1);
}
&:disabled {
opacity: 0.3;
cursor: not-allowed;
}
}
} }
} }
@@ -98,6 +98,19 @@ export class ChatInterfaceComponent {
}); });
} }
/**
* Stop an individual task session
*/
public stopTask(sessionId: string, event: MouseEvent): void {
event.stopPropagation();
this.isSubmitting = true;
this.errorMessage = null;
this.agentService.stopTask(sessionId, false);
setTimeout(() => {
this.isSubmitting = false;
}, 400);
}
/** /**
* Stop the currently running task and optionally clear the backend queue * Stop the currently running task and optionally clear the backend queue
*/ */
@@ -1375,6 +1375,31 @@
<span>{{ isRefreshingDiagnostics() ? 'Checking...' : 'Check Again' }}</span> <span>{{ isRefreshingDiagnostics() ? 'Checking...' : 'Check Again' }}</span>
</button> </button>
</div> </div>
<!-- Multi-Device Switcher if multiple devices detected -->
@if (connectedDevices().length > 1) {
<div class="multi-device-switcher" style="margin-top: 12px;">
<span class="switcher-label">Select Active Device:</span>
<div class="switcher-chips">
@for (dev of connectedDevices(); track dev.serial) {
<button
type="button"
class="device-chip-btn"
[class.selected]="dev.serial === activeDevice()?.serial"
(click)="selectTargetDevice(dev.serial)"
>
<span class="material-symbols-outlined chip-icon">
phone_android
</span>
<span>{{ dev.model || dev.serial }}</span>
@if (dev.serial === activeDevice()?.serial) {
<span class="active-check">✓ Active</span>
}
</button>
}
</div>
</div>
}
</div> </div>
} }
@@ -116,11 +116,11 @@
<span class="material-symbols-outlined">close</span> <span class="material-symbols-outlined">close</span>
</button> </button>
<!-- Stop Button (Visible when task is running) --> <!-- Stop Button (Visible when currently viewed task is active) -->
<button *ngIf="isTaskRunning()" type="button" class="dock-circle-btn btn-stop" [disabled]="isSubmitting" <button *ngIf="isTaskRunning()" type="button" class="dock-circle-btn btn-stop" [disabled]="isSubmitting"
(click)="stopTask($event)" (click)="stopTask($event)"
title="Stop running task" [title]="stopButtonTitle()"
aria-label="Stop running task" [attr.aria-label]="stopButtonTitle()"
> >
<span class="material-symbols-outlined">stop</span> <span class="material-symbols-outlined">stop</span>
</button> </button>
@@ -161,12 +161,12 @@
</div> </div>
</div> </div>
<!-- Separate Standalone Stop Button Beside the Capsule (Visible when task is running) --> <!-- Separate Standalone Stop Button Beside the Capsule (Visible when currently viewed task is active) -->
<button *ngIf="isTaskRunning()" type="button" class="standalone-stop-btn" <button *ngIf="isTaskRunning()" type="button" class="standalone-stop-btn"
[disabled]="isSubmitting" [disabled]="isSubmitting"
(click)="stopTask($event)" (click)="stopTask($event)"
title="Stop running task" [title]="stopButtonTitle()"
aria-label="Stop running task" [attr.aria-label]="stopButtonTitle()"
> >
<span class="material-symbols-outlined">stop</span> <span class="material-symbols-outlined">stop</span>
</button> </button>
@@ -77,10 +77,29 @@ export class WorkspaceComponent implements OnInit {
} }
/** /**
* Computed boolean whether a task is currently executing or active * Computed boolean whether the currently viewed task is actively running or paused.
* Only displays the stop/cancel button when inspecting an active task.
*/ */
public isTaskRunning = computed(() => { public isTaskRunning = computed(() => {
return this.agentService.isRunningTask(); return this.agentService.isCurrentSessionRunning();
});
/**
* Dynamic tooltip and label indicating which task will be stopped
*/
public stopButtonTitle = computed(() => {
const session = this.agentService.currentSession();
if (session?.initial_goal) {
const truncated = session.initial_goal.length > 45
? session.initial_goal.substring(0, 42) + '...'
: session.initial_goal;
return `Stop current task: "${truncated}"`;
}
const curId = this.agentService.currentSessionId();
if (curId) {
return `Stop current task (${curId})`;
}
return 'Stop current running task';
}); });
/** /**
@@ -216,7 +235,7 @@ export class WorkspaceComponent implements OnInit {
} }
/** /**
* Stop currently running task * Stop currently viewed task
*/ */
public stopTask(event?: MouseEvent): void { public stopTask(event?: MouseEvent): void {
if (event) { if (event) {
@@ -225,9 +244,10 @@ export class WorkspaceComponent implements OnInit {
if (!this.isTaskRunning()) { if (!this.isTaskRunning()) {
return; return;
} }
const targetSessionId = this.agentService.currentSessionId();
this.isSubmitting = true; this.isSubmitting = true;
this.errorMessage = null; this.errorMessage = null;
this.agentService.stopTask(false); this.agentService.stopTask(targetSessionId, false);
setTimeout(() => { setTimeout(() => {
this.isSubmitting = false; this.isSubmitting = false;
}, 400); }, 400);
@@ -275,3 +275,108 @@ describe('AgentService video analysis seeking', () => {
expect(second?.requestId).toBeGreaterThan(first?.requestId || 0); expect(second?.requestId).toBeGreaterThan(first?.requestId || 0);
}); });
}); });
describe('AgentService task cancellation and active session tracking', () => {
it('computes isCurrentSessionRunning true only when viewing an active running/paused session', () => {
const service = Object.create(AgentService.prototype) as any;
service.currentSessionId = signal<string | null>(null);
service.rawSessions = signal<any[]>([]);
service.activeTasks = signal<any[]>([]);
service.pendingQueue = signal<any[]>([]);
service.agentStatus = signal('idle');
service.runningSessionId = signal<string | null>(null);
service.runningGoal = signal<string | null>(null);
service.activeSessionTracking = new Map();
service.currentSession = computed(() => {
const curId = service.currentSessionId();
if (!curId) return null;
return service.sessions().find((s: any) => s.session_id === curId) || null;
});
service.sessions = computed(() => {
return service.rawSessions();
});
service.isCurrentSessionRunning = computed(() => {
const curId = service.currentSessionId();
if (!curId) return false;
const session = service.currentSession();
if (session) {
return session.status === 'running' || session.status === 'paused';
}
const isActiveStatus = service.agentStatus() === 'running' || service.agentStatus() === 'paused';
if (isActiveStatus && service.runningSessionId() === curId) {
return true;
}
if (service.activeTasks().some((at: any) => at.session_id === curId)) {
return true;
}
return false;
});
// 1. No session selected -> false
expect(service.isCurrentSessionRunning()).toBeFalse();
// 2. Completed session selected -> false
service.rawSessions.set([
{ session_id: 'sess-completed', status: 'completed', initial_goal: 'Done', start_time: 100 },
{ session_id: 'sess-running', status: 'running', initial_goal: 'Running', start_time: 101 }
]);
service.currentSessionId.set('sess-completed');
expect(service.isCurrentSessionRunning()).toBeFalse();
// 3. Active running session selected -> true
service.currentSessionId.set('sess-running');
expect(service.isCurrentSessionRunning()).toBeTrue();
// 4. Paused session selected -> true
service.rawSessions.set([
{ session_id: 'sess-paused', status: 'paused', initial_goal: 'Paused', start_time: 102 }
]);
service.currentSessionId.set('sess-paused');
expect(service.isCurrentSessionRunning()).toBeTrue();
});
it('stops a specific session by passing its session_id', () => {
const service = Object.create(AgentService.prototype) as AgentService;
service.currentSessionId = signal<string | null>('sess-2');
service.runningSessionId = signal<string | null>('sess-1');
service.runningGoal = signal<string | null>('Goal 1');
service.agentStatus = signal('running');
service.isPaused = signal(false);
service.pausedError = signal<string | null>(null);
service.isRetrying = signal(false);
service.sessionLogs = signal<any[]>([]);
service.activeTasks = signal<any[]>([{ session_id: 'sess-1' }, { session_id: 'sess-2' }]);
(service as any).pendingQueue = signal<any[]>([]);
(service as any).rawSessions = signal<any[]>([
{ session_id: 'sess-1', status: 'running', initial_goal: 'Goal 1' },
{ session_id: 'sess-2', status: 'running', initial_goal: 'Goal 2' }
]);
service.sessions = computed(() => (service as any).rawSessions());
let postedUrl = '';
let postedPayload: any = null;
(service as any).http = {
post: (url: string, payload: any) => {
postedUrl = url;
postedPayload = payload;
return of({});
}
};
(service as any).setSessionStatus = (sid: string, st: string) => {};
(service as any).fetchStatus = () => {};
(service as any).fetchSessions = () => {};
// Stop sess-2 specifically
service.stopTask('sess-2', false);
expect(postedUrl).toContain('session_id=sess-2');
expect(postedPayload?.session_id).toBe('sess-2');
// Because sess-1 is still running, agentStatus should NOT be reset to idle
expect(service.agentStatus()).toBe('running');
// activeTasks should have filtered out sess-2
expect(service.activeTasks().map((at: any) => at.session_id)).toEqual(['sess-1']);
});
});
+182 -130
View File
@@ -275,12 +275,25 @@ export class AgentService {
}); });
/** /**
* Computed whether the currently viewed session is running * Computed whether the currently viewed session is actively running or paused.
* Returns false when viewing completed/failed/cancelled tasks, or when no session is selected.
*/ */
public isCurrentSessionRunning = computed(() => { public isCurrentSessionRunning = computed(() => {
const curId = this.currentSessionId();
if (!curId) return false;
const session = this.currentSession(); const session = this.currentSession();
if (session) return session.status === 'running' || session.status === 'paused'; if (session) {
return this.agentStatus() === 'running' || this.agentStatus() === 'paused'; return session.status === 'running' || session.status === 'paused';
}
// Fallback if session was just submitted/selected but not yet merged into sessions list
const isActiveStatus = this.agentStatus() === 'running' || this.agentStatus() === 'paused';
if (isActiveStatus && this.runningSessionId() === curId) {
return true;
}
if (this.activeTasks().some((at) => at.session_id === curId)) {
return true;
}
return false;
}); });
/** /**
@@ -312,6 +325,7 @@ export class AgentService {
this.restoreSessionsCache(); this.restoreSessionsCache();
this.fetchSessions(); this.fetchSessions();
this.startStatusPolling(); this.startStatusPolling();
this.ensureLiveStream();
} }
/** /**
@@ -382,43 +396,86 @@ export class AgentService {
} }
/** /**
* Stop the currently running task (and optionally clear queue) * Stop the currently viewed task (or specified task), and optionally clear queue.
* Supports stopping any task across any ingress/platform (Web, CLI, MCP, SDK).
*/ */
public stopTask(stopAll: boolean = false): void { public stopTask(targetOrStopAll?: string | boolean | null, stopAll: boolean = false): void {
const stoppingSessionId = this.runningSessionId() let targetSessionId: string | null = null;
|| this.sessions().find((session) => session.status === 'running' || session.status === 'paused')?.session_id let effectiveStopAll = stopAll;
|| null;
if (typeof targetOrStopAll === 'boolean') {
effectiveStopAll = targetOrStopAll;
} else if (typeof targetOrStopAll === 'string' && targetOrStopAll.trim()) {
targetSessionId = targetOrStopAll.trim();
} else {
// Default to currently viewed session, or currently running session
targetSessionId = this.currentSessionId() || this.runningSessionId() || null;
}
if (!effectiveStopAll && !targetSessionId) {
targetSessionId = 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 // Keep the task's terminal state stable while the stop request and the
// persisted session update cross the network. Clearing the global runner // persisted session update cross the network. Clearing the global runner
// state first used to make the session merger infer "completed" from the // state first used to make the session merger infer "completed" from the
// still-stale DB row, causing a brief completed -> cancelled flicker. // still-stale DB row, causing a brief completed -> cancelled flicker.
this.setSessionStatus(stoppingSessionId, 'cancelled'); if (targetSessionId) {
this.setSessionStatus(targetSessionId, 'cancelled');
}
// Apply optimistic updates: only set idle if stopAll is true or pending queue is empty // Determine if other active sessions are still executing across multiple devices
if (stopAll || this.pendingQueue().length === 0) { const otherRunningSessions = this.sessions().filter(
(s) => (s.status === 'running' || s.status === 'paused') && s.session_id !== targetSessionId
);
// Apply optimistic updates: only set idle if effectiveStopAll is true or no other tasks are running
if (effectiveStopAll || otherRunningSessions.length === 0) {
this.agentStatus.set('idle'); this.agentStatus.set('idle');
this.runningSessionId.set(null); this.runningSessionId.set(null);
this.runningGoal.set(null); this.runningGoal.set(null);
} else if (this.runningSessionId() === targetSessionId) {
this.runningSessionId.set(otherRunningSessions[0].session_id);
this.runningGoal.set(otherRunningSessions[0].initial_goal || null);
} }
this.isPaused.set(false); this.isPaused.set(false);
this.pausedError.set(null); this.pausedError.set(null);
this.isRetrying.set(false); this.isRetrying.set(false);
if (stopAll) { if (effectiveStopAll) {
this.pendingQueue.set([]); this.pendingQueue.set([]);
} }
// Mark active streaming / pending logs in current session as finished // Mark active streaming / pending logs as finished if viewing target session
this.sessionLogs.update((logs) => if (!targetSessionId || this.currentSessionId() === targetSessionId) {
logs.map((l) => { this.sessionLogs.update((logs) =>
if (l.type === 'llm_stream' && !l.data?.isCompleted) { logs.map((l) => {
return { ...l, data: { ...l.data, isCompleted: true } }; if (l.type === 'llm_stream' && !l.data?.isCompleted) {
} return { ...l, data: { ...l.data, isCompleted: true } };
return l; }
}) return l;
); })
);
}
this.http.post<any>(`/api/stop?all=${stopAll}`, {}).subscribe({ // Optimistically remove from activeTasks
if (targetSessionId) {
this.activeTasks.update((list) => list.filter((at) => at.session_id !== targetSessionId));
}
let url = `/api/stop?all=${effectiveStopAll}`;
if (!effectiveStopAll && targetSessionId) {
url += `&session_id=${encodeURIComponent(targetSessionId)}`;
}
const payload: any = { all: effectiveStopAll };
if (targetSessionId) {
payload.session_id = targetSessionId;
}
this.http.post<any>(url, payload).subscribe({
next: () => { next: () => {
this.fetchStatus(); this.fetchStatus();
this.fetchSessions(); this.fetchSessions();
@@ -577,10 +634,6 @@ export class AgentService {
this.currentSessionId.set(null); this.currentSessionId.set(null);
this.sessionLogs.set([]); this.sessionLogs.set([]);
this.currentNotes.set({}); this.currentNotes.set({});
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
return; return;
} }
@@ -605,37 +658,36 @@ export class AgentService {
this.openVideoPlayer(sessionId); this.openVideoPlayer(sessionId);
} }
// Clean up previous event stream connection if any console.log(`Selecting session: ${sessionId}`);
// Start reading the persisted snapshot immediately
this.backfillSessionSteps(sessionId, loadGeneration);
this.ensureLiveStream();
}
/**
* Ensure persistent single-channel live stream is established via /api/stream.
* Eliminates reconnect tearing, race conditions, and disconnect dropouts.
*/
public ensureLiveStream(): void {
if (this.eventSource) { if (this.eventSource) {
this.eventSource.close(); return;
this.eventSource = null;
} }
console.log(`Connecting to session: ${sessionId}`); console.log('Establishing persistent unified live stream via /api/stream');
// Start reading the persisted snapshot immediately. Previously this waited this.eventSource = new EventSource('/api/stream');
// for the SSE `info` event, adding connection latency to every history click.
this.backfillSessionSteps(sessionId, loadGeneration);
this.eventSource = new EventSource(`/api/stream/${sessionId}`);
// Active runs get one reconciliation snapshot after the stream subscribes,
// closing the snapshot-to-stream gap without delaying the first paint.
const selectedStatus = this.sessions().find((session) => session.session_id === sessionId)?.status;
const shouldReconcileAfterSubscribe = selectedStatus === 'running'
|| selectedStatus === 'paused'
|| selectedStatus === 'pending';
this.eventSource.addEventListener('info', () => { this.eventSource.addEventListener('info', () => {
if (shouldReconcileAfterSubscribe) { // Reconcile current session if active
this.backfillSessionSteps(sessionId, loadGeneration); const curId = this.currentSessionId();
if (curId) {
this.backfillSessionSteps(curId);
} }
}); });
// Listen to standard SSE keep-alive
this.eventSource.addEventListener('keep-alive', () => { this.eventSource.addEventListener('keep-alive', () => {
// No-op keep-alive tick // Keep-alive tick
}); });
// Listen to raw events and append them directly to the logs array
const eventTypes = [ const eventTypes = [
'llm_stream', 'llm_stream',
'trace_recorded', 'trace_recorded',
@@ -656,8 +708,6 @@ export class AgentService {
this.eventSource?.addEventListener(eventType, (event: MessageEvent) => { this.eventSource?.addEventListener(eventType, (event: MessageEvent) => {
try { try {
const parsedData = JSON.parse(event.data); const parsedData = JSON.parse(event.data);
// If the event carries a session_id for a different session, update global states if applicable and ignore
const evtSessionId = parsedData?.session_id; const evtSessionId = parsedData?.session_id;
if (eventType === 'recording_ready' || eventType === 'recording_failed') { if (eventType === 'recording_ready' || eventType === 'recording_failed') {
@@ -682,50 +732,8 @@ export class AgentService {
return; return;
} }
const isDifferentSession = evtSessionId if (eventType === 'background_tasks_updated') {
&& String(evtSessionId).trim().toLowerCase() !== String(sessionId).trim().toLowerCase(); this.fetchStatus();
if (isDifferentSession) {
if (eventType === 'session_started') {
this.agentStatus.set('running');
this.runningSessionId.set(parsedData.session_id);
if (parsedData?.initial_goal) {
this.runningGoal.set(parsedData.initial_goal);
}
this.fetchSessions();
// Only auto-select if user has no current session at all
const currentId = this.currentSessionId();
if (!currentId && parsedData?.session_id) {
this.selectSession(parsedData.session_id, false);
}
} else if (eventType === 'session_ended') {
this.applySessionEndedStatus(evtSessionId, parsedData);
this.activeTasks.update(list => list.filter(at => at.session_id !== evtSessionId));
const remaining = this.activeTasks();
if (remaining.length > 0) {
if (this.runningSessionId() === evtSessionId) {
this.runningSessionId.set(remaining[0].session_id);
this.runningGoal.set(remaining[0].goal || null);
}
} else if (this.pendingQueue().length > 0) {
const nextPending = this.pendingQueue()[0];
this.agentStatus.set('running');
this.runningSessionId.set(nextPending.session_id);
this.runningGoal.set(nextPending.initial_goal || null);
} else {
this.agentStatus.set('idle');
this.runningSessionId.set(null);
this.runningGoal.set(null);
}
this.fetchStatus();
this.fetchSessions();
this.updateModelForCurrentView();
}
return;
}
// Guard against obsolete subscriptions
if (this.currentSessionId() !== sessionId) {
return; return;
} }
@@ -738,12 +746,20 @@ export class AgentService {
this.runningGoal.set(parsedData.initial_goal); this.runningGoal.set(parsedData.initial_goal);
} }
this.fetchSessions(); this.fetchSessions();
// Auto-follow: if user has not explicitly pinned a historical session, switch view to new active session
if (!this.userPinnedSessionId() && parsedData?.session_id) {
this.selectSession(parsedData.session_id, false);
}
return; return;
} }
if (eventType === 'session_ended') { if (eventType === 'session_ended') {
this.applySessionEndedStatus(sessionId, parsedData); const endedId = evtSessionId || this.runningSessionId();
this.activeTasks.update(list => list.filter(at => at.session_id !== sessionId)); if (endedId) {
this.applySessionEndedStatus(endedId, parsedData);
this.activeTasks.update(list => list.filter(at => at.session_id !== endedId));
}
const remaining = this.activeTasks(); const remaining = this.activeTasks();
if (remaining.length > 0) { if (remaining.length > 0) {
this.agentStatus.set('running'); this.agentStatus.set('running');
@@ -763,20 +779,40 @@ export class AgentService {
this.isPaused.set(false); this.isPaused.set(false);
this.fetchSessions(); this.fetchSessions();
this.fetchStatus(); this.fetchStatus();
this.fetchNotes(sessionId); if (endedId) {
this.updateModelForCurrentView(); this.fetchNotes(endedId);
if ( if (
this.isVideoWindowOpen() this.isVideoWindowOpen()
&& this.activeVideoSessionId === sessionId && this.activeVideoSessionId === endedId
&& this.recordingPlaybackStatus() === 'live' && this.recordingPlaybackStatus() === 'live'
) { ) {
this.beginRecordingFinalization(sessionId); this.beginRecordingFinalization(endedId);
}
} }
this.updateModelForCurrentView();
return; return;
} }
if (eventType === 'startup_progress') { if (eventType === 'startup_progress') {
this.appendStartupProgress(parsedData, sessionId); const targetSid = evtSessionId || this.currentSessionId();
if (targetSid) {
this.appendStartupProgress(parsedData, targetSid);
const curId = this.currentSessionId();
if (
!this.userPinnedSessionId() &&
(!curId || String(targetSid).trim().toLowerCase() !== String(curId).trim().toLowerCase())
) {
this.agentStatus.set('running');
this.runningSessionId.set(targetSid);
this.selectSession(targetSid, false);
}
}
return;
}
// If the event is for a different session than what user is currently viewing, ignore for logs
const curId = this.currentSessionId();
if (evtSessionId && curId && String(evtSessionId).trim().toLowerCase() !== String(curId).trim().toLowerCase()) {
return; return;
} }
@@ -788,7 +824,9 @@ export class AgentService {
const attemptText = attempt && max ? ` (Attempt ${attempt}/${max})` : ''; const attemptText = attempt && max ? ` (Attempt ${attempt}/${max})` : '';
const delayText = delay > 0 ? `; retrying in ${delay.toFixed(2).replace(/\.00$/, '')}s` : ''; const delayText = delay > 0 ? `; retrying in ${delay.toFixed(2).replace(/\.00$/, '')}s` : '';
this.retryMessage.set(`AI service is temporarily busy${attemptText}${delayText}...`); this.retryMessage.set(`AI service is temporarily busy${attemptText}${delayText}...`);
this.appendLiveLLMRetryTrace(parsedData, sessionId); if (curId) {
this.appendLiveLLMRetryTrace(parsedData, curId);
}
return; return;
} }
@@ -796,17 +834,19 @@ export class AgentService {
this.isPaused.set(true); this.isPaused.set(true);
this.isRetrying.set(false); this.isRetrying.set(false);
this.agentStatus.set('paused'); this.agentStatus.set('paused');
this.runningSessionId.set(sessionId); if (curId) {
this.setSessionStatus(sessionId, 'paused'); this.runningSessionId.set(curId);
const pauseError = parsedData.error || 'AI call failed'; this.setSessionStatus(curId, 'paused');
this.pausedError.set(pauseError); const pauseError = parsedData.error || 'AI call failed';
this.appendPausedErrorCard( this.pausedError.set(pauseError);
pauseError, this.appendPausedErrorCard(
sessionId, pauseError,
parsedData.timestamp, curId,
parsedData.step_id, parsedData.timestamp,
parsedData parsedData.step_id,
); parsedData
);
}
return; return;
} }
@@ -814,9 +854,9 @@ export class AgentService {
this.isPaused.set(false); this.isPaused.set(false);
this.isRetrying.set(false); this.isRetrying.set(false);
this.pausedError.set(null); this.pausedError.set(null);
if (this.runningSessionId() === sessionId) { if (curId && this.runningSessionId() === curId) {
this.agentStatus.set('running'); this.agentStatus.set('running');
this.setSessionStatus(sessionId, 'running'); this.setSessionStatus(curId, 'running');
} }
this.activePauseCardKey = null; this.activePauseCardKey = null;
return; return;
@@ -833,7 +873,6 @@ export class AgentService {
const streamType = parsedData.stream_type || 'text'; const streamType = parsedData.stream_type || 'text';
this.sessionLogs.update((logs) => { this.sessionLogs.update((logs) => {
// Find if there is an active stream log with same execution_id and stream_type
const existingIndex = logs.findIndex( const existingIndex = logs.findIndex(
(l) => l.type === 'llm_stream' && (l) => l.type === 'llm_stream' &&
l.data.execution_id === execId && l.data.execution_id === execId &&
@@ -841,7 +880,6 @@ export class AgentService {
); );
if (existingIndex > -1) { if (existingIndex > -1) {
// Append text in place
const updatedLogs = [...logs]; const updatedLogs = [...logs];
const existingLog = updatedLogs[existingIndex]; const existingLog = updatedLogs[existingIndex];
updatedLogs[existingIndex] = { updatedLogs[existingIndex] = {
@@ -854,7 +892,6 @@ export class AgentService {
}; };
return updatedLogs; return updatedLogs;
} else { } else {
// First mark any other active streams of same stream_type as completed
const updatedLogs = logs.map((l) => { const updatedLogs = logs.map((l) => {
if (l.type === 'llm_stream' && if (l.type === 'llm_stream' &&
(l.data.stream_type || 'text') === streamType && (l.data.stream_type || 'text') === streamType &&
@@ -867,7 +904,6 @@ export class AgentService {
return l; return l;
}); });
// Add new stream log
return [ return [
...updatedLogs, ...updatedLogs,
{ {
@@ -885,7 +921,6 @@ export class AgentService {
} }
}); });
} else { } else {
// For other event types, mark any active stream as completed
this.sessionLogs.update((logs) => { this.sessionLogs.update((logs) => {
const updatedLogs = logs.map((l) => { const updatedLogs = logs.map((l) => {
if (l.type === 'llm_stream' && !l.data.isCompleted) { if (l.type === 'llm_stream' && !l.data.isCompleted) {
@@ -907,9 +942,6 @@ export class AgentService {
data: parsedData data: parsedData
}; };
// A retry is published both as trace_recorded and as the
// purpose-built llm_retrying event. Replace the optimistic live
// copy when the persisted trace arrives instead of duplicating it.
if (eventType === 'trace_recorded' && parsedData?.trace_id) { if (eventType === 'trace_recorded' && parsedData?.trace_id) {
const existingTraceIndex = updatedLogs.findIndex((log) => const existingTraceIndex = updatedLogs.findIndex((log) =>
log.type === 'trace_recorded' log.type === 'trace_recorded'
@@ -930,14 +962,13 @@ export class AgentService {
const trName = parsedData.name || ''; const trName = parsedData.name || '';
if (['save_note', 'read_note', 'update_note', 'append_note', 'list_notes', 'outputter'].includes(trName.toLowerCase())) { if (['save_note', 'read_note', 'update_note', 'append_note', 'list_notes', 'outputter'].includes(trName.toLowerCase())) {
const curSessionId = this.currentSessionId(); const curSessionId = this.currentSessionId();
if (curSessionId === sessionId) { if (curSessionId) {
this.fetchNotes(curSessionId); this.fetchNotes(curSessionId);
} }
} }
} }
} catch (e) { } catch (e) {
console.error(`Failed to parse ${eventType} event data:`, e); console.error(`Failed to parse ${eventType} event data:`, e);
// Fallback to plain event data if parsing fails
this.sessionLogs.update((logs) => [ this.sessionLogs.update((logs) => [
...logs, ...logs,
{ {
@@ -951,7 +982,7 @@ export class AgentService {
}); });
this.eventSource.onerror = (err) => { this.eventSource.onerror = (err) => {
console.error('EventSource connection error:', err); console.warn('Persistent live stream issue, browser will auto-reconnect:', err);
}; };
} }
@@ -998,6 +1029,27 @@ export class AgentService {
} }
} }
}); });
this.http.get<StartupProgressEvent[]>(`/api/sessions/${sessionId}/startup_progress`).subscribe({
next: (events) => {
if (Array.isArray(events) && events.length > 0) {
this.startupProgressBySession.update((current) => {
const existing = current[sessionId] || [];
const merged = [...existing];
for (const ev of events) {
if (!merged.some((m) => m.stage === ev.stage)) {
merged.push(ev);
}
}
return {
...current,
[sessionId]: merged
};
});
}
},
error: () => {}
});
} }
private restoreSessionsCache(): void { private restoreSessionsCache(): void {
@@ -1268,10 +1320,10 @@ export class AgentService {
this.beginRecordingFinalization(oldRunningSessionId); this.beginRecordingFinalization(oldRunningSessionId);
} }
// Auto-select the session ONLY if nothing is currently selected // Auto-select the active session if user has not explicitly pinned a historical session
if (isActive && data.session_id) { if (isActive && data.session_id) {
const currentId = this.currentSessionId(); const currentId = this.currentSessionId();
if (!currentId) { if (!currentId || (!this.userPinnedSessionId() && currentId !== data.session_id)) {
this.selectSession(data.session_id, false); this.selectSession(data.session_id, false);
} }
} }
+12 -3
View File
@@ -90,7 +90,14 @@ class ReadinessEngine:
def get_active_device_serial(self) -> str | None: def get_active_device_serial(self) -> str | None:
"""Get currently selected active device serial.""" """Get currently selected active device serial."""
return self._active_device_serial if self._report_cache and self._report_cache.active_device:
if self._report_cache.active_device.is_locked is False:
return self._report_cache.active_device.serial
if self._active_device_serial:
return self._active_device_serial
if self._report_cache and self._report_cache.active_device:
return self._report_cache.active_device.serial
return None
async def run_probe(self, probe_id: str) -> ProbeResult | None: async def run_probe(self, probe_id: str) -> ProbeResult | None:
"""Execute a single specific probe by ID.""" """Execute a single specific probe by ID."""
@@ -99,9 +106,11 @@ class ReadinessEngine:
return None return None
return await probe.probe() return await probe.probe()
async def run_device_submission_probe(self) -> ProbeResult: async def run_device_submission_probe(
self, target_serial: str | None = None
) -> ProbeResult:
"""Run the bounded device gate used by task submission.""" """Run the bounded device gate used by task submission."""
return await self._adb_probe.probe_submission_readiness() return await self._adb_probe.probe_submission_readiness(target_serial=target_serial)
def invalidate_cache(self) -> None: def invalidate_cache(self) -> None:
"""Invalidate the UI readiness snapshot after an explicit configuration change.""" """Invalidate the UI readiness snapshot after an explicit configuration change."""
+45 -7
View File
@@ -358,7 +358,9 @@ class AdbDeviceProbe(BaseProbe):
logger.debug(f"Failed submission-time ADB device check: {exc}") logger.debug(f"Failed submission-time ADB device check: {exc}")
return None return None
async def probe_submission_readiness(self) -> ProbeResult: async def probe_submission_readiness(
self, target_serial: str | None = None
) -> ProbeResult:
"""Run the minimal fail-safe device check required before enqueueing. """Run the minimal fail-safe device check required before enqueueing.
The full diagnostics probe enriches device metadata, scans packages, The full diagnostics probe enriches device metadata, scans packages,
@@ -416,16 +418,42 @@ class AdbDeviceProbe(BaseProbe):
}, },
) )
serial = ( preferred = (
self._target_serial target_serial
if target_serial and target_serial in ready_serials
else self._target_serial
if self._target_serial in ready_serials if self._target_serial in ready_serials
else ready_serials[0] else ready_serials[0]
) )
serial = preferred
is_locked = await self._get_confirmed_device_lock_state( is_locked = await self._get_confirmed_device_lock_state(
adb_path, adb_path,
serial, serial,
timeout_seconds=1.0, timeout_seconds=1.0,
) )
# If preferred device is locked or unknown, check remaining ready devices for an unlocked one
if is_locked is not False and len(ready_serials) > 1:
for candidate in ready_serials:
if candidate == preferred:
continue
candidate_locked = await self._get_confirmed_device_lock_state(
adb_path,
candidate,
timeout_seconds=1.0,
)
if candidate_locked is False:
logger.info(
f"Device {preferred} is not ready/unlocked ({is_locked}); "
f"switching to unlocked ready device {candidate}"
)
serial = candidate
is_locked = False
break
elif candidate_locked is None and is_locked is True:
serial = candidate
is_locked = None
summary = ( summary = (
"Device Locked" "Device Locked"
if is_locked is True if is_locked is True
@@ -796,11 +824,21 @@ class AdbDeviceProbe(BaseProbe):
) )
# 4. Ready device available # 4. Ready device available
active_dev = ready_devices[0] preferred_dev = None
if self._target_serial: if self._target_serial:
matched = next((d for d in ready_devices if d.serial == self._target_serial), None) preferred_dev = next((d for d in ready_devices if d.serial == self._target_serial), None)
if matched:
active_dev = matched if preferred_dev and preferred_dev.is_locked is False:
active_dev = preferred_dev
else:
# Prefer a confirmed unlocked ready device
unlocked_dev = next((d for d in ready_devices if d.is_locked is False), None)
if unlocked_dev:
active_dev = unlocked_dev
else:
# If no confirmed unlocked device, prefer undetermined lock state over confirmed locked
unknown_dev = next((d for d in ready_devices if d.is_locked is None), None)
active_dev = preferred_dev or unknown_dev or ready_devices[0]
display_name = active_dev.model or active_dev.serial display_name = active_dev.model or active_dev.serial
if active_dev.screen_resolution: if active_dev.screen_resolution:
+3 -3
View File
@@ -77,7 +77,6 @@ async def execute_task(
explorer_flash_mode: Override Explorer version mode for Flash execution profile. explorer_flash_mode: Override Explorer version mode for Flash execution profile.
explorer_pro_mode: Override Explorer version mode for Pro execution profile. explorer_pro_mode: Override Explorer version mode for Pro execution profile.
""" """
publish_startup_progress("configuration", "Loading the run configuration")
effective_sid = ( effective_sid = (
session_id session_id
or os.getenv("ARTEMIS_SESSION_ID") or os.getenv("ARTEMIS_SESSION_ID")
@@ -85,6 +84,9 @@ async def execute_task(
) )
if effective_sid: if effective_sid:
os.environ["ARTEMIS_SESSION_ID"] = str(effective_sid) os.environ["ARTEMIS_SESSION_ID"] = str(effective_sid)
publish_startup_progress(
"configuration", "Loading the run configuration", session_id=str(effective_sid) if effective_sid else None
)
llm_config = initialize_llm_config() llm_config = initialize_llm_config()
agent_profile = AgentProfile(name="default", llm_config=llm_config) agent_profile = AgentProfile(name="default", llm_config=llm_config)
@@ -142,13 +144,11 @@ async def execute_task(
agent: Agent | None = None agent: Agent | None = None
try: try:
publish_startup_progress("device_check", "Checking the Android device")
agent = Agent(config=config.build(), session_id=effective_sid) agent = Agent(config=config.build(), session_id=effective_sid)
await agent.init( await agent.init(
retry_count=int(os.getenv("ARTEMIS_HEALTH_RETRIES", 5)), retry_count=int(os.getenv("ARTEMIS_HEALTH_RETRIES", 5)),
retry_wait_seconds=int(os.getenv("ARTEMIS_HEALTH_DELAY", 2)), retry_wait_seconds=int(os.getenv("ARTEMIS_HEALTH_DELAY", 2)),
) )
publish_startup_progress("device_ready", "Android device connected")
task = agent.new_task(goal) task = agent.new_task(goal)
if locked_app_package: if locked_app_package:
@@ -23,12 +23,21 @@ from typing import Annotated
import urllib.error import urllib.error
import urllib.request import urllib.request
from artemis.interfaces.cli.commands.server_lifecycle import (
restart_command,
status_command,
stop_command,
)
from artemis.utils.logger import get_logger from artemis.utils.logger import get_logger
import typer import typer
logger = get_logger(__name__) logger = get_logger(__name__)
server_app = typer.Typer(help="Cloud proxy and server management commands.") server_app = typer.Typer(help="Cloud proxy and server management commands.")
server_app.command("restart", help="Restart running Artemis Web UI & server.")(restart_command)
server_app.command("stop", help="Stop running Artemis Web UI & server.")(stop_command)
server_app.command("status", help="Display Artemis Web UI & server status.")(status_command)
cached_token = "" cached_token = ""
token_expire_time = 0.0 token_expire_time = 0.0
+6
View File
@@ -158,6 +158,9 @@ def submit_task_to_daemon(
enable_outputter: bool | None = None, enable_outputter: bool | None = None,
locked_app_package: str | None = None, locked_app_package: str | None = None,
app_path: str | None = None, app_path: str | None = None,
session_id: str | None = None,
ingress: str = "client",
conversation_id: str | None = None,
base_url: str | None = None, base_url: str | None = None,
timeout: float = 5.0, timeout: float = 5.0,
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
@@ -174,6 +177,9 @@ def submit_task_to_daemon(
"enable_outputter": enable_outputter, "enable_outputter": enable_outputter,
"locked_app_package": locked_app_package, "locked_app_package": locked_app_package,
"app_path": app_path, "app_path": app_path,
"session_id": session_id,
"ingress": ingress,
"conversation_id": conversation_id,
} }
try: try:
+10
View File
@@ -223,6 +223,16 @@ class DevicePool:
# If not detected by ADB, still return it so ADB can attempt connection # If not detected by ADB, still return it so ADB can attempt connection
return preferred_serial return preferred_serial
# Prefer active ready device from readiness engine if available
try:
from artemis.core.diagnostics import readiness_engine
active_serial = readiness_engine.get_active_device_serial()
if active_serial and any(d.serial == active_serial and d.state == "device" for d in all_devs):
return active_serial
except Exception:
pass
# Pick first idle device # Pick first idle device
for dev in ready_devs: for dev in ready_devs:
if not dev.is_busy: if not dev.is_busy:
+6 -4
View File
@@ -203,6 +203,7 @@ class Agent:
raise DeviceNotFoundError(error_msg) raise DeviceNotFoundError(error_msg)
# Initialize clients # Initialize clients
publish_startup_progress("device_check", "Checking the Android device", session_id=self._session_id)
if os.environ.get("ARTEMIS_CLOUD_MODE") != "1": if os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
self._init_clients( self._init_clients(
device_id=device_id, device_id=device_id,
@@ -221,6 +222,7 @@ class Agent:
device_id=device_id, platform=platform device_id=device_id, platform=platform
) )
logger.info(self._device_context.to_str()) logger.info(self._device_context.to_str())
publish_startup_progress("device_ready", "Android device connected", session_id=self._session_id)
# Asynchronously pre-warm LLM connection pools in the background # Asynchronously pre-warm LLM connection pools in the background
asyncio.create_task(self._prewarm_llm_connections(api_key)) asyncio.create_task(self._prewarm_llm_connections(api_key))
@@ -580,15 +582,15 @@ class Agent:
if os.environ.get("ARTEMIS_CLOUD_MODE") != "1": if os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
if self._ui_adb_client is not None: if self._ui_adb_client is not None:
publish_startup_progress( publish_startup_progress(
"uiautomator", "Connecting to UI Automator", session_id=str(task.id) "uiautomator", "Connecting to UI Automator", session_id=str(sess_id)
) )
await asyncio.to_thread(self._ui_adb_client.connect) await asyncio.to_thread(self._ui_adb_client.connect)
publish_startup_progress( publish_startup_progress(
"uiautomator_ready", "UI Automator is ready", session_id=str(task.id) "uiautomator_ready", "UI Automator is ready", session_id=str(sess_id)
) )
await self._ensure_device_unlocked() await self._ensure_device_unlocked()
publish_startup_progress( publish_startup_progress(
"environment", "Preparing the device environment", session_id=str(task.id) "environment", "Preparing the device environment", session_id=str(sess_id)
) )
await self._prepare_app_installation(task=task) await self._prepare_app_installation(task=task)
await self._prepare_device_environment(context=context) await self._prepare_device_environment(context=context)
@@ -620,7 +622,7 @@ class Agent:
logger.error(f"[{task_name}] Failed to start screen recording: {e}") logger.error(f"[{task_name}] Failed to start screen recording: {e}")
publish_startup_progress( publish_startup_progress(
"environment_ready", "Device environment is ready", session_id=str(task.id) "environment_ready", "Device environment is ready", session_id=str(sess_id)
) )
try: try:
if request.profile and request.profile.lower() == "flash": if request.profile and request.profile.lower() == "flash":
+31 -68
View File
@@ -24,6 +24,7 @@ from PIL import Image, ImageDraw
from mcp_server.base import mcp from mcp_server.base import mcp
from mcp_server.utils import env_utils, trace_store from mcp_server.utils import env_utils, trace_store
from artemis.utils.visualization import draw_action_overlay_on_image
from artemis.utils.task_tree import ( from artemis.utils.task_tree import (
_render_step_detailed, _render_step_detailed,
build_plan_and_history, build_plan_and_history,
@@ -31,79 +32,41 @@ from artemis.utils.task_tree import (
def _draw_action_overlay(bg_image_path: str, action_obj: dict, output_path: str) -> bool: def _draw_action_overlay(bg_image_path: str, action_obj: dict, output_path: str) -> bool:
"""Draws the action (tap, swipe, etc.) on the image and saves it.""" """Draws the action overlay on the image using Artemis's native visualization engine."""
try: try:
with Image.open(bg_image_path) as img: if not os.path.exists(bg_image_path):
draw_img = img.convert("RGBA") return False
draw = ImageDraw.Draw(draw_img)
act_type = action_obj.get("action") or action_obj.get("name") with open(bg_image_path, "rb") as f:
coords = action_obj.get("coordinates") or action_obj.get("target") raw_bytes = f.read()
if not act_type or not coords: act_name = action_obj.get("action") or action_obj.get("name") or ""
return False action_args = dict(action_obj.get("args") or {})
for k in (
"target",
"coordinates",
"point",
"direction",
"sequence",
"targets",
"normalized_coordinates",
):
if k in action_obj and k not in action_args:
action_args[k] = action_obj[k]
fill_color = (255, 0, 0, 80) annotated_bytes = draw_action_overlay_on_image(
outline_color = (255, 0, 0, 255) image_bytes=raw_bytes,
outline_width = 4 action_name=act_name,
action_args=action_args,
)
if act_type in ( if not annotated_bytes or annotated_bytes == raw_bytes:
"tap", return False
"click",
"long_press",
"focus_and_input_text",
"focus_and_clear_text",
):
if isinstance(coords, list) and len(coords) == 2:
x, y = coords[0], coords[1]
r = 25
draw.ellipse(
[x - r, y - r, x + r, y + r],
fill=fill_color,
outline=outline_color,
width=outline_width,
)
r_center = 4
draw.ellipse(
[
x - r_center,
y - r_center,
x + r_center,
y + r_center,
],
fill=outline_color,
outline=outline_color,
)
elif act_type == "swipe":
if isinstance(coords, list) and len(coords) == 4:
x1, y1, x2, y2 = coords[0], coords[1], coords[2], coords[3]
draw.line([x1, y1, x2, y2], fill=outline_color, width=6)
r_start = 8
draw.ellipse(
[
x1 - r_start,
y1 - r_start,
x1 + r_start,
y1 + r_start,
],
fill=outline_color,
)
dx = x2 - x1
dy = y2 - y1
angle = math.atan2(dy, dx)
arrow_length = 30
arrow_angle = math.pi / 6
p1_x = x2 - arrow_length * math.cos(angle - arrow_angle) os.makedirs(os.path.dirname(output_path), exist_ok=True)
p1_y = y2 - arrow_length * math.sin(angle - arrow_angle) with open(output_path, "wb") as f:
p2_x = x2 - arrow_length * math.cos(angle + arrow_angle) f.write(annotated_bytes)
p2_y = y2 - arrow_length * math.sin(angle + arrow_angle) return True
draw.polygon([x2, y2, p1_x, p1_y, p2_x, p2_y], fill=outline_color)
final_img = draw_img.convert("RGB")
final_img.save(output_path, "JPEG", quality=85)
return True
except Exception as e: except Exception as e:
print(f"Error drawing action overlay: {e}", file=sys.stderr) print(f"Error drawing action overlay: {e}", file=sys.stderr)
return False return False
@@ -434,7 +397,7 @@ async def mobile_inspect_trace(action: str, trace_id: str, step_number: int | No
action_obj[0] if isinstance(action_obj, list) and action_obj else action_obj action_obj[0] if isinstance(action_obj, list) and action_obj else action_obj
) )
if isinstance(action_parsed, dict) and action_parsed.get("action"): if isinstance(action_parsed, dict) and (action_parsed.get("action") or action_parsed.get("name")):
if not os.path.exists(overlay_abs_path): if not os.path.exists(overlay_abs_path):
success_draw = _draw_action_overlay( success_draw = _draw_action_overlay(
path, action_parsed, overlay_abs_path path, action_parsed, overlay_abs_path
+51 -6
View File
@@ -162,16 +162,53 @@ def mobile_run_task(
device_serial=device_serial, device_serial=device_serial,
) )
# 3. Auto-spawn daemon if not already running (unless standalone forced) # 3. Dispatch via unified Artemis Daemon scheduler if available (unless standalone forced)
if os.environ.get("ARTEMIS_STANDALONE") != "1": if os.environ.get("ARTEMIS_STANDALONE") != "1":
try: try:
from artemis.runtime import ensure_daemon_running from artemis.runtime import ensure_daemon_running, submit_task_to_daemon
ensure_daemon_running(timeout=1.5) is_running, base_url = ensure_daemon_running(timeout=2.0, wait_ready=True)
except Exception: if is_running:
pass resp = submit_task_to_daemon(
goal=task_desc,
profile=canonical_model.lower(),
device_serial=device_serial,
expected_output=expected_output_desc if canonical_model != "Flash" else None,
locked_app_package=locked_app_package,
app_path=app_path,
session_id=trace_id,
ingress="mcp",
conversation_id=conversation_id,
base_url=base_url,
)
if resp and resp.get("tasks"):
assigned_sid = resp["tasks"][0].get("session_id", trace_id)
trace_store.update_trace_status(
assigned_sid, "running", device_serial=device_serial
)
notes_dir = str(trace_store.get_trace_notes_dir(assigned_sid))
stdout_log = str(trace_store.get_trace_stdout_log_path(assigned_sid))
stderr_log = str(trace_store.get_trace_stderr_log_path(assigned_sid))
return {
"trace_id": assigned_sid,
"status": "running",
"message": (
f"Autonomous task '{task_desc}' enqueued via unified Artemis Daemon.\n"
f"Trace ID: {assigned_sid}\n"
f"Model: {canonical_model}\n"
f"Live telemetry: streaming to web workspace."
),
"notes_dir": notes_dir,
"stdout_log": stdout_log,
"stderr_log": stderr_log,
}
except Exception as exc:
import logging
logging.getLogger("mcp_server").warning(
f"Daemon dispatch failed, falling back to standalone runner: {exc}"
)
# 4. Resolve project root, python executable, and background runner module # 4. Standalone Fallback: Resolve project root, python executable, and background runner module
project_root = env_utils.get_project_root() project_root = env_utils.get_project_root()
python_exe = env_utils.resolve_python_executable(project_root) python_exe = env_utils.resolve_python_executable(project_root)
reserve_kwargs: dict[str, Any] = { reserve_kwargs: dict[str, Any] = {
@@ -214,6 +251,14 @@ def mobile_run_task(
env["ADB_DEVICE_SERIAL"] = device_serial env["ADB_DEVICE_SERIAL"] = device_serial
env["ARTEMIS_DEVICE_ID"] = device_serial env["ARTEMIS_DEVICE_ID"] = device_serial
env[DeviceExecutionLock.QUEUE_TICKET_ENV] = queue_ticket env[DeviceExecutionLock.QUEUE_TICKET_ENV] = queue_ticket
try:
from artemis.config.runtime import read_ipc_port
ipc_port = read_ipc_port()
if ipc_port:
env["ARTEMIS_IPC_PORT"] = str(ipc_port)
except Exception:
pass
proc_kwargs = env_utils.get_detached_process_kwargs() proc_kwargs = env_utils.get_detached_process_kwargs()
proc = subprocess.Popen( proc = subprocess.Popen(
@@ -59,6 +59,33 @@ async def test_cancelled_event_stream_reaps_queue_waiter():
assert _pending_queue_get_tasks() == queue_tasks_before assert _pending_queue_get_tasks() == queue_tasks_before
@pytest.mark.asyncio
async def test_stream_events_active_session_replay():
"""Verify stream_events with default 'active' replays running session info and steps."""
state.is_shutting_down = False
state.shutdown_event.clear()
state.active_session_id = "replay-sess-123"
state.current_goal = "Replay Goal"
state.current_profile = "flash"
with patch("apps.admin_console.database.repositories.step_repository.step_repo") as mock_step_repo:
mock_step_repo.get_session_steps.return_value = [
{"step_number": 1, "description": "Step 1", "status": "completed"}
]
response = await stream_events()
iterator = response.body_iterator
first_event = await anext(iterator)
assert "Subscribed to session active" in first_event
second_event = await anext(iterator)
assert "session_started" in second_event
assert "replay-sess-123" in second_event
third_event = await anext(iterator)
assert "step_recorded" in third_event
assert "Step 1" in third_event
await iterator.aclose()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_windows_sigint_is_ignored_while_task_is_active(): async def test_windows_sigint_is_ignored_while_task_is_active():
state.current_process = MagicMock(returncode=None) state.current_process = MagicMock(returncode=None)
@@ -74,3 +74,41 @@ async def test_run_task_enqueues_when_device_is_unlocked(monkeypatch):
assert result["status"] == "started" assert result["status"] == "started"
enqueue_tasks.assert_awaited_once() enqueue_tasks.assert_awaited_once()
@pytest.mark.asyncio
async def test_run_task_switches_to_unlocked_device_and_syncs_engine(monkeypatch):
"""When default device is locked, run_task should enqueue on verified fallback and update engine."""
unlocked_probe = ProbeResult(
id="android_adb",
category=ProbeCategory.DEVICE,
title="Device / Emulator Connected",
status=ProbeStatus.PASS,
is_blocker=True,
summary="Connected",
description="Ready.",
metadata={
"installed": True,
"submission_probe": True,
"active_device": {"serial": "emulator-5556", "is_locked": False},
},
)
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)
monkeypatch.setattr(tasks.readiness_engine, "get_active_device_serial", lambda: "device-locked-1")
set_active_mock = AsyncMock()
monkeypatch.setattr(tasks.readiness_engine, "set_active_device_serial", set_active_mock)
result = await tasks.run_task(RunRequest(goal="Run on any ready device"))
assert result["status"] == "started"
enqueue_tasks.assert_awaited_once()
# Check that device_serial passed to enqueue_tasks is the verified unlocked device
_, kwargs = enqueue_tasks.call_args
assert kwargs.get("device_serial") == "emulator-5556"
set_active_mock.assert_called_once_with("emulator-5556")
@@ -607,3 +607,198 @@ def test_darwin_terminate_process_tree_preserves_direct_child_for_asyncio():
for call_args in mock_wait_procs.call_args_list: for call_args in mock_wait_procs.call_args_list:
procs_waited = call_args[0][0] procs_waited = call_args[0][0]
assert parent not in procs_waited assert parent not in procs_waited
@pytest.mark.asyncio
async def test_enqueue_tasks_unified_ingress():
"""Verify enqueue_tasks correctly propagates ingress, custom session_id, and conversation_id."""
with patch.object(TaskQueueService, "ensure_worker_running"):
res = await task_queue_service.enqueue_tasks(
["Test unified goal"],
profile="flash",
ingress="mcp",
session_id="custom-mcp-session-123",
conversation_id="conv-456",
)
assert res["status"] in ("started", "queued")
assert len(res["tasks"]) == 1
task = res["tasks"][0]
assert task["session_id"] == "custom-mcp-session-123"
assert task["ingress"] == "mcp"
assert task["conversation_id"] == "conv-456"
assert task["goal"] == "Test unified goal"
@pytest.mark.asyncio
async def test_queue_worker_notifies_conversation():
"""Verify queue_worker calls notify() when conversation_id is attached to task."""
executed_cmds = []
async def fake_subprocess_exec(*args, **kwargs):
executed_cmds.append(list(args))
proc = MagicMock()
proc.pid = 99999
proc.wait = AsyncMock(return_value=0)
proc.returncode = 0
return proc
with (
patch("asyncio.create_subprocess_exec", side_effect=fake_subprocess_exec),
patch("apps.admin_console.services.task_queue_service.media_service"),
patch("mcp_server.notifiers.notify") as mock_notify,
patch("apps.admin_console.services.task_queue_service.session_repo") as mock_repo,
):
mock_repo.get_running_session_id.return_value = None
await task_queue_service.enqueue_tasks(
["Notify goal"],
profile="flash",
ingress="mcp",
conversation_id="conv-789",
device_serial="test-mock-serial",
)
for _ in range(30):
if mock_notify.called and len(state.queue_items) == 0:
break
await asyncio.sleep(0.05)
mock_notify.assert_called_once()
kwargs = mock_notify.call_args[1]
assert kwargs["conversation_id"] == "conv-789"
assert "Notify goal" in kwargs["message"]
task = state.worker_task
if task and not task.done():
task.cancel()
try:
await task
except (asyncio.CancelledError, Exception):
pass
def test_stop_tasks_by_session_id_targets_correct_task_among_multiple():
"""Stopping by session_id terminates only the targeted owner among multiple concurrent devices."""
owner_a = DeviceLockOwner(
pid=11111,
process_created_at=100.0,
token="token-a",
device_id="device-a",
description="Task on device A",
acquired_at="2026-08-24T00:00:00+00:00",
session_id="session-a",
ingress="cli",
)
owner_b = DeviceLockOwner(
pid=22222,
process_created_at=200.0,
token="token-b",
device_id="device-b",
description="Task on device B",
acquired_at="2026-08-24T00:00:00+00:00",
session_id="session-b",
ingress="mcp",
)
with (
patch(
"apps.admin_console.services.task_queue_service.DeviceExecutionLock.get_active_owners",
return_value={"device_a": owner_a, "device_b": owner_b},
),
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"
) as cleanup_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,
):
# Explicitly stop session-b
assert task_queue_service.stop_tasks(clear_all=False, session_id="session-b") is True
# Only session-b's process (22222) should be terminated
terminate_verified.assert_called_once_with(22222, 200.0)
cleanup_locks.assert_called_once_with("device-b")
update_status.assert_called_once()
assert update_status.call_args[0][0] == "session-b"
assert update_status.call_args[0][1] == "cancelled"
update_trace.assert_called_once_with(
"session-b",
"cancelled",
error="Task stopped from the Artemis frontend.",
)
def test_stop_tasks_by_device_id_targets_specific_device():
"""Stopping by device_id targets the active task on that specific device."""
owner_a = DeviceLockOwner(
pid=33333,
process_created_at=300.0,
token="token-a",
device_id="pixel-8",
description="Task on Pixel 8",
acquired_at="2026-08-24T00:00:00+00:00",
session_id="session-pixel8",
ingress="sdk",
)
with (
patch(
"apps.admin_console.services.task_queue_service.DeviceExecutionLock.get_active_owners",
return_value={"pixel_8": owner_a},
),
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"),
):
assert task_queue_service.stop_tasks(clear_all=False, device_id="pixel-8") is True
terminate_verified.assert_called_once_with(33333, 300.0)
def test_stop_tasks_queued_item_by_session_id():
"""Stopping a queued task by session_id cancels its reservation and removes it from queue."""
state.queue_items = [
{"session_id": "queue-1", "goal": "Goal 1", "status": "pending", "queue_ticket": "ticket-1"},
{"session_id": "queue-2", "goal": "Goal 2", "status": "pending", "queue_ticket": "ticket-2"},
]
with (
patch(
"apps.admin_console.services.task_queue_service.DeviceExecutionLock.get_active_owners",
return_value={},
),
patch(
"apps.admin_console.services.task_queue_service.DeviceExecutionLock.get_active_owner",
return_value=None,
),
patch(
"apps.admin_console.services.task_queue_service.DeviceExecutionLock.cancel_reservation",
return_value=True,
) as cancel_res,
patch("apps.admin_console.services.task_queue_service.session_repo.update_session_status") as update_status,
):
assert task_queue_service.stop_tasks(clear_all=False, session_id="queue-1") is True
cancel_res.assert_called_once_with("ticket-1")
# queue-2 should remain in queue
assert len(state.queue_items) == 1
update_status.assert_called_once()
assert update_status.call_args[0][0] == "queue-1"
assert update_status.call_args[0][1] == "cancelled"
+49
View File
@@ -313,6 +313,55 @@ async def test_submission_probe_fails_closed_when_lock_state_is_unknown(monkeypa
assert result.status == ProbeStatus.WARN assert result.status == ProbeStatus.WARN
@pytest.mark.asyncio
async def test_submission_probe_falls_back_to_unlocked_device(monkeypatch):
"""When the first device is locked but a second device is unlocked, submission probe falls back."""
probe = AdbDeviceProbe()
monkeypatch.setattr(
probe,
"_get_device_states",
AsyncMock(return_value=[("device-locked", "device"), ("device-unlocked", "device")]),
)
async def mock_lock_state(adb_path, serial, timeout_seconds=1.0):
return True if serial == "device-locked" else False
monkeypatch.setattr(probe, "_get_confirmed_device_lock_state", mock_lock_state)
result = await probe.probe_submission_readiness()
assert result.status == ProbeStatus.PASS
assert result.summary == "Connected"
assert result.metadata["active_device"]["serial"] == "device-unlocked"
assert result.metadata["active_device"]["is_locked"] is False
@pytest.mark.asyncio
async def test_adb_probe_prefers_unlocked_device_when_one_is_locked(monkeypatch):
"""When multiple ready devices exist, probe() should pick the unlocked one as active."""
from artemis.core.diagnostics.schema import DeviceInfo
probe = AdbDeviceProbe()
monkeypatch.setattr(probe, "_locate_adb", lambda: "/usr/bin/adb")
monkeypatch.setattr(probe, "_get_adb_version", AsyncMock(return_value="1.0.41"))
monkeypatch.setattr(probe, "_locate_emulator", lambda: "/usr/bin/emulator")
monkeypatch.setattr(probe, "_list_installed_avds", lambda _: [])
devices = [
DeviceInfo(serial="dev-locked-1", state="device", model="Pixel 7", is_locked=True),
DeviceInfo(serial="dev-unlocked-2", state="device", model="Pixel 8", is_locked=False),
]
monkeypatch.setattr(probe, "_parse_adb_devices", AsyncMock(return_value=devices))
result = await probe.probe()
assert result.status == ProbeStatus.PASS
assert result.summary == "Connected"
assert result.metadata["active_device"]["serial"] == "dev-unlocked-2"
assert result.metadata["active_device"]["is_locked"] is False
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_llm_credentials_probe_structure(): async def test_llm_credentials_probe_structure():
"""Verify LLMCredentialsProbe returns correct category and schema.""" """Verify LLMCredentialsProbe returns correct category and schema."""
+16 -1
View File
@@ -12,6 +12,7 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
import json
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest import pytest
@@ -92,12 +93,26 @@ def test_submit_task_to_daemon():
mock_resp.read.return_value = b'{"status": "queued", "tasks": [{"session_id": "test-sess"}]}' mock_resp.read.return_value = b'{"status": "queued", "tasks": [{"session_id": "test-sess"}]}'
mock_resp.__enter__.return_value = mock_resp mock_resp.__enter__.return_value = mock_resp
with patch("urllib.request.urlopen", return_value=mock_resp): with patch("urllib.request.urlopen", return_value=mock_resp) as mock_urlopen:
res = submit_task_to_daemon( res = submit_task_to_daemon(
"test goal", "test goal",
profile="flash", profile="flash",
device_serial="pixel-8", device_serial="pixel-8",
session_id="custom-sess-789",
ingress="mcp",
conversation_id="conv-123",
) )
assert res is not None assert res is not None
assert res["status"] == "queued" assert res["status"] == "queued"
assert res["tasks"][0]["session_id"] == "test-sess" assert res["tasks"][0]["session_id"] == "test-sess"
# Verify request payload
req = mock_urlopen.call_args[0][0]
data = json.loads(req.data.decode("utf-8"))
assert data["goal"] == "test goal"
assert data["profile"] == "flash"
assert data["device_serial"] == "pixel-8"
assert data["session_id"] == "custom-sess-789"
assert data["ingress"] == "mcp"
assert data["conversation_id"] == "conv-123"
+19
View File
@@ -436,3 +436,22 @@ def test_cli_restart_command(monkeypatch):
assert stopped["port"] == 8888 assert stopped["port"] == 8888
assert ui_called["port"] == 8888 assert ui_called["port"] == 8888
assert ui_called["open_browser"] is False assert ui_called["open_browser"] is False
def test_cli_server_lifecycle_aliases(monkeypatch):
"""Verify 'artemis server status/stop/restart' subcommands are accessible."""
from artemis.runtime import server_lifecycle
monkeypatch.setattr(server_lifecycle, "is_port_in_use", lambda port, **kwargs: False)
monkeypatch.setattr(server_lifecycle, "find_server_pids", lambda port: [])
monkeypatch.setattr(server_lifecycle, "read_server_info", lambda: None)
result = runner.invoke(app, ["server", "status", "--port", "59998"])
assert result.exit_code == 0
assert "OFFLINE" in result.output or "STOPPED" in result.output
help_result = runner.invoke(app, ["server", "--help"])
assert help_result.exit_code == 0
assert "restart" in help_result.output
assert "stop" in help_result.output
assert "status" in help_result.output
+11 -6
View File
@@ -16,7 +16,7 @@
from pathlib import Path from pathlib import Path
import tempfile import tempfile
from unittest.mock import MagicMock from unittest.mock import MagicMock, patch
import pytest import pytest
from pydantic import SecretStr from pydantic import SecretStr
@@ -218,8 +218,12 @@ def test_output_config_and_recording():
def test_runtime_state_and_ipc(tmp_path, monkeypatch): def test_runtime_state_and_ipc(tmp_path, monkeypatch):
"""Test IPC port and LS address state helpers.""" """Test IPC port and LS address state helpers."""
import urllib.request
monkeypatch.setattr(urllib.request, "urlopen", MagicMock(side_effect=Exception("offline")))
ipc_state_file = tmp_path / ".artemis_ipc_port" ipc_state_file = tmp_path / ".artemis_ipc_port"
monkeypatch.setattr("artemis.config.runtime.get_ipc_port_file", lambda: ipc_state_file) monkeypatch.setattr("artemis.config.runtime.get_ipc_port_file", lambda: ipc_state_file)
monkeypatch.setattr("artemis.config.runtime.ROOT_DIR", tmp_path)
monkeypatch.setattr("artemis.config.runtime.get_app_dir", lambda: tmp_path)
# Test IPC port # Test IPC port
write_ipc_port(49152) write_ipc_port(49152)
assert read_ipc_port() == 49152 assert read_ipc_port() == 49152
@@ -365,8 +369,8 @@ def test_checker_builder_and_context_propagation():
mock_task.request.record_trace = False mock_task.request.record_trace = False
mock_task.request.name = "test_task" mock_task.request.name = "test_task"
mock_task.request.profile = None mock_task.request.profile = None
mock_task.request.goal = "Test goal" with patch("artemis.sdk.agent.DataEngine"):
agent._prepare_tracing(mock_task, ctx) agent._prepare_tracing(mock_task, ctx)
assert ctx.execution_setup is not None assert ctx.execution_setup is not None
assert ctx.execution_setup.disable_checker is False assert ctx.execution_setup.disable_checker is False
@@ -455,8 +459,8 @@ def test_explorer_builder_and_resolution(monkeypatch):
mock_task.request.record_trace = False mock_task.request.record_trace = False
mock_task.request.name = "explorer_test_task" mock_task.request.name = "explorer_test_task"
mock_task.request.profile = None mock_task.request.profile = None
mock_task.request.goal = "Test explorer propagation" with patch("artemis.sdk.agent.DataEngine"):
agent._prepare_tracing(mock_task, ctx) agent._prepare_tracing(mock_task, ctx)
assert ctx.execution_setup is not None assert ctx.execution_setup is not None
assert ctx.execution_setup.explorer_version == "pro" assert ctx.execution_setup.explorer_version == "pro"
@@ -505,7 +509,8 @@ def test_outputter_builder_and_context_propagation():
mock_task.request.name = "outputter_test_task" mock_task.request.name = "outputter_test_task"
mock_task.request.profile = None mock_task.request.profile = None
mock_task.request.goal = "Test outputter propagation" mock_task.request.goal = "Test outputter propagation"
agent._prepare_tracing(mock_task, ctx) with patch("artemis.sdk.agent.DataEngine"):
agent._prepare_tracing(mock_task, ctx)
assert ctx.execution_setup is not None assert ctx.execution_setup is not None
assert ctx.execution_setup.disable_outputter is False assert ctx.execution_setup.disable_outputter is False