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

+8
View File
@@ -161,6 +161,14 @@ async def get_session_background_tasks(session_id: str):
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")
async def cleanup_history_endpoint():
try:
+93 -10
View File
@@ -16,7 +16,7 @@ import asyncio
from contextlib import suppress
import json
from typing import Any
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
from artemis.core.diagnostics import readiness_engine
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
# bounded submission probe: the full diagnostics path also scans packages,
# emulator installations, Android version, and screen size.
device_probe = await readiness_engine.run_device_submission_probe()
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"}:
locked_serial = (
device_probe.metadata.get("active_device", {}).get("serial")
or target_serial
or ""
)
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"
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)
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(
incoming_goals,
profile=request.profile or "flash",
@@ -100,7 +115,10 @@ async def run_task(request: RunRequest):
enable_outputter=request.enable_outputter,
locked_app_package=request.locked_app_package,
app_path=request.app_path,
device_serial=request.device_serial,
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")
async def stop_task(all: bool = False):
stopped = task_queue_service.stop_tasks(clear_all=all)
async def stop_task(
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:
return {"status": "stopped"}
return {"status": "stopped", "session_id": target_sid}
return {"status": "no_running_task"}
@router.post("/api/resume")
async def 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}")
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():
queue = asyncio.Queue()
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)
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):
yield (
"event: startup_progress\n"
+10
View File
@@ -24,6 +24,9 @@ class RunRequest(BaseModel):
locked_app_package: str | None = None
app_path: 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
tool_name: str = "ask_explorer"
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:
try:
return await asyncio.wait_for(proc.wait(), timeout=1.0)
except asyncio.TimeoutError:
except TimeoutError:
pid = getattr(proc, "pid", None)
if pid:
try:
@@ -313,7 +313,7 @@ class TaskQueueService:
env["ARTEMIS_IPC_PORT"] = str(state.ipc_port)
if sess_id:
env["ARTEMIS_SESSION_ID"] = str(sess_id)
env["ARTEMIS_TASK_INGRESS"] = "frontend"
env["ARTEMIS_TASK_INGRESS"] = str(task_item.get("ingress", "frontend"))
queue_ticket = task_item.get("queue_ticket")
if queue_ticket:
env[DeviceExecutionLock.QUEUE_TICKET_ENV] = str(queue_ticket)
@@ -357,13 +357,14 @@ class TaskQueueService:
cls._broadcast_startup_progress(
sess_id, "process_ready", "Execution process started"
)
ingress_type = str(task_item.get("ingress", "frontend"))
DeviceExecutionLock.transfer_reservation(
str(queue_ticket),
proc.pid,
description=f"frontend task: {goal[:120]}",
description=f"{ingress_type} task: {goal[:120]}",
device_id=device_serial or "pending",
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):
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:
print("[QueueWorker] Task received cancellation signal.")
if state.current_process and state.current_process.returncode is None:
@@ -467,6 +483,9 @@ class TaskQueueService:
locked_app_package: str | None = None,
app_path: str | None = None,
device_serial: str | None = None,
ingress: str = "frontend",
session_id: str | None = None,
conversation_id: str | None = None,
) -> dict[str, Any]:
"""Enqueues one or more goals and wakes up the background worker."""
cls.ensure_worker_running()
@@ -474,8 +493,15 @@ class TaskQueueService:
enqueued_tasks = []
now = time.time()
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
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:
try:
from artemis.runtime import device_pool
@@ -485,10 +511,10 @@ class TaskQueueService:
assigned_serial = None
queue_ticket = DeviceExecutionLock.reserve(
description=f"frontend task: {goal[:120]}",
description=f"{ingress} task: {goal[:120]}",
device_id=assigned_serial or "pending",
session_id=sess_id,
ingress="frontend",
ingress=ingress,
)
task_item = {
"session_id": sess_id,
@@ -499,6 +525,8 @@ class TaskQueueService:
"locked_app_package": locked_app_package,
"app_path": app_path,
"device_serial": assigned_serial,
"ingress": ingress,
"conversation_id": conversation_id,
"status": "pending",
"queue_ticket": queue_ticket,
"created_at": now + i * 0.001,
@@ -521,17 +549,132 @@ class TaskQueueService:
}
@classmethod
def stop_tasks(cls, clear_all: bool = False) -> bool:
"""Stop the one task currently controlling the global Artemis device.
def stop_tasks(
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
processes. Pending tasks are intentionally preserved unless the legacy
``clear_all`` option is explicitly requested, in which case only this
frontend server's pending submissions are cleared.
processes across all connected devices.
- If ``session_id`` or ``device_id`` is specified, only the corresponding
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
owner = DeviceExecutionLock.get_active_owner()
owner_record_exists = DeviceExecutionLock.has_owner_record()
target_sid = str(session_id).strip() if session_id else None
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
local_pid = getattr(state.current_process, "pid", None)
is_local_owner = bool(owner_pid and local_pid and owner_pid == local_pid)
@@ -540,7 +683,9 @@ class TaskQueueService:
(
item
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,
)
@@ -548,37 +693,38 @@ class TaskQueueService:
(
item
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,
)
stopped_session_id = (
owner.session_id
if owner and owner.session_id
else target_sid
if target_sid
else state.active_session_id
if owner
if is_local_owner
else local_item.get("session_id")
if local_item
else None
)
if clear_all:
for item in state.queue_items:
if isinstance(item, dict) and item.get("status") != "running":
DeviceExecutionLock.cancel_reservation(item.get("queue_ticket"))
state.clear_queue()
reservation_cancelled = False
if owner and DeviceExecutionLock.is_active_owner(owner):
stopped = process_supervisor.terminate_tree_verified(
owner.pid,
owner.process_created_at,
)
DeviceExecutionLock.cleanup_stale_locks()
elif owner is None and owner_record_exists:
DeviceExecutionLock.cleanup_stale_locks(owner.device_id)
elif owner is None and owner_record_exists and not target_sid:
# Never fall back to a frontend PID while another process has an
# owner record that is still being published or cannot be parsed.
return False
elif owner is None and state.current_process:
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
# window before Agent acquires the global device lease.
state.was_stopped_manually = True
@@ -592,21 +738,42 @@ class TaskQueueService:
stopped = True
except Exception:
pass
# A dead worker is already stopped; cleanup must still be treated
# as a successful, idempotent stop operation.
stopped = True
is_local_owner = True
elif owner is None and local_item:
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
# does not touch pending reservations created by other ingresses.
DeviceExecutionLock.cancel_reservation(local_item.get("queue_ticket"))
reservation_cancelled = True
stopped = 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 clear_all:
state.wake_event.set()
return clear_all
if not stopped and not target_sid:
return False
if is_local_owner:
state.was_stopped_manually = True
@@ -614,11 +781,11 @@ class TaskQueueService:
if stopped_session_id:
state.cancelled_session_ids.add(str(stopped_session_id))
for session_id, conn_info in list(state.active_connections.items()):
if (stopped_session_id and str(session_id) == str(stopped_session_id)) or (
for sid, conn_info in list(state.active_connections.items()):
if (stopped_session_id and str(sid) == str(stopped_session_id)) or (
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:
session_repo.update_session_status(
@@ -652,7 +819,7 @@ class TaskQueueService:
state.current_goal = None
state.current_profile = None
if is_local_owner and stopped_session_id:
if stopped_session_id:
stopped_item = next(
(
item
@@ -662,7 +829,7 @@ class TaskQueueService:
),
None,
)
if stopped_item:
if stopped_item and not reservation_cancelled:
DeviceExecutionLock.cancel_reservation(stopped_item.get("queue_ticket"))
state.queue_items = [
item
@@ -672,8 +839,6 @@ class TaskQueueService:
and str(item.get("session_id")) == str(stopped_session_id)
)
]
if clear_all:
state.queue_items = []
if PAUSE_FILE.exists():
try:
@@ -685,6 +850,7 @@ class TaskQueueService:
state.wake_event.set()
return True
@classmethod
def resume_task(cls) -> bool:
if PAUSE_FILE.exists():