feat: introduce MCP server, cross-platform runtime, and Showcase UI reorganization

- Showcase UI Reorganization:
  * Move web UI dashboard from root directory into apps/showcase_ui/ for cleaner project structure
  * Add 'artemis ui' CLI command (artemis/interfaces/cli/commands/ui.py) and launcher scripts for the interactive showcase console
- Standalone MCP Server & Notifiers (mcp_server/):
  * Implement dedicated MCP server package with mobile automation tools (device_state, inspect_trace, task_manager, task_runner)
  * Add multi-channel notifiers (AgentAPI, Desktop notifications, Webhooks, File output, and Composite routing)
  * Introduce background task runner and trace store for autonomous mobile execution monitoring
- Cross-Platform Runtime & Toolchain Support:
  * Add artemis/platform/ with OS-specific implementations for Linux, macOS (Darwin), and Windows
  * Add artemis/runtime/supervisor.py for resilient process and agent runtime supervision
  * Add artemis/toolchain/ with dependency resolution and SDK descriptors
  * Add cross-platform launcher scripts (start.sh, start.bat) and installer scripts (scripts/install_deps.*)
- Core Enhancements & Diagnostics:
  * Enhance coordinate normalization, OCR API fallback, and perception pipeline
  * Add credential validation (credentials_validator.py) and system diagnostics
  * Update bilingual documentation (README.md, README_CN.md) and architecture diagrams
- Test Suite Expansion:
  * Add integration tests for cloud gateway, remote ADB, and Artemis login
  * Add unit tests covering core diagnostics, platform detection, toolchain, credentials validator, optional OCR, and MCP tools
This commit is contained in:
somew1nd committed 2026-08-18 13:59:27 -07:00
1 parent 78e478f502
commit 9a5aaf99b7
249 files changed
+37226 -9339

No files matched your search

@@ -24,6 +24,8 @@ import subprocess
import time
from collections.abc import AsyncGenerator
from artemis.toolchain import find_adb
logger = logging.getLogger("artemis.stream_service")
@@ -41,8 +43,9 @@ class DeviceStreamService:
async def get_device_serial(self) -> str | None:
"""Find the currently connected active ADB device serial."""
try:
adb_bin = find_adb()
proc = await asyncio.create_subprocess_exec(
"adb",
adb_bin,
"devices",
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
@@ -64,10 +67,11 @@ class DeviceStreamService:
try:
start_t = time.time()
serial = await self.get_device_serial()
adb_bin = find_adb()
cmd = (
["adb", "-s", serial, "exec-out", "screencap", "-p"]
[adb_bin, "-s", serial, "exec-out", "screencap", "-p"]
if serial
else ["adb", "exec-out", "screencap", "-p"]
else [adb_bin, "exec-out", "screencap", "-p"]
)
proc = await asyncio.create_subprocess_exec(
*cmd,
+12 -3
View File
@@ -78,7 +78,7 @@ class IPCService:
except Exception:
pass
if tr_type == "tool" and isinstance(payload, dict):
if tr_type in ("tool", "action") and isinstance(payload, dict):
args = payload.get("args", payload)
data["payload"] = {"args": cls._clean_value(args)}
elif tr_type == "llm_call" and isinstance(payload, dict):
@@ -144,8 +144,17 @@ class IPCService:
data = payload.get("data")
if event_type == "session_started" and data:
state.active_session_id = data.get("session_id")
current_session_id = data.get("session_id")
incoming_sid = data.get("session_id")
if incoming_sid and str(incoming_sid) in getattr(
state, "cancelled_session_ids", set()
):
print(
f"[IPC] Ignoring session_started for cancelled session: {incoming_sid}"
)
continue
state.active_session_id = incoming_sid
current_session_id = incoming_sid
pid = data.get("pid")
goal = data.get("initial_goal")
device_info = data.get("device_info")
+5 -4
View File
@@ -26,18 +26,18 @@ class ModelService:
if "pro" in p_lower:
return {
"name": "Pro",
"id": "gemini-3.6-pro",
"id": "gemini-3.7-pro",
"provider": "google",
}
elif "flash" in p_lower:
return {
"name": "Flash",
"id": "gemini-3.6-flash",
"id": "gemini-3.7-flash",
"provider": "google",
}
model_name = "Flash"
model_id = "gemini-3.6-flash"
model_id = "gemini-3.7-flash"
provider = "google"
try:
from artemis.config import parse_llm_config
@@ -118,7 +118,8 @@ class ModelService:
m_str = str(p_obj.get("model") or p_obj.get("model_name") or "").lower()
if (
"gemini-3.6-pro" in m_str
"gemini-3.7-pro" in m_str
or "gemini-3.6-pro" in m_str
or "gemini-1.5-pro" in m_str
or "gpt-4" in m_str
or "claude" in m_str
@@ -0,0 +1,514 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Preset Task Catalog & Dynamic Recommendation Engine for Artemis."""
from typing import Any, Literal
from pydantic import BaseModel, Field
class AppInfo(BaseModel):
"""Application metadata."""
name: str
icon: str
pkg: str
category: str = "general"
class TaskPreset(BaseModel):
"""Smart suggestion task definition."""
id: str
title: str
description: str
goal: str
profile: Literal["flash", "pro"]
category: Literal["flash", "pro", "cross_app", "monitor"]
tag: str
apps: list[AppInfo]
required_packages: list[str] = Field(default_factory=list)
match_mode: Literal["any", "all"] = "any"
priority: int = 50
# ============================================================================
# APP PACKAGE REGISTRY
# ============================================================================
APP_REGISTRY: dict[str, dict[str, str]] = {
# Google Suite & System
"com.google.android.apps.maps": {"name": "Maps", "icon": "explore", "category": "navigation"},
"com.google.android.gm": {"name": "Gmail", "icon": "mail", "category": "productivity"},
"com.android.chrome": {"name": "Chrome", "icon": "public", "category": "browser"},
"com.google.android.youtube": {
"name": "YouTube",
"icon": "smart_display",
"category": "entertainment",
},
"com.android.settings": {"name": "Settings", "icon": "settings", "category": "system"},
"com.google.android.deskclock": {"name": "Clock", "icon": "timer", "category": "utility"},
"com.android.deskclock": {"name": "Clock", "icon": "timer", "category": "utility"},
"com.google.android.calculator": {
"name": "Calculator",
"icon": "calculate",
"category": "utility",
},
"com.android.calculator2": {"name": "Calculator", "icon": "calculate", "category": "utility"},
"com.google.android.apps.photos": {
"name": "Photos",
"icon": "photo_library",
"category": "media",
},
"com.google.android.calendar": {
"name": "Calendar",
"icon": "calendar_month",
"category": "productivity",
},
"com.google.android.keep": {
"name": "Keep Notes",
"icon": "note_alt",
"category": "productivity",
},
"com.android.vending": {"name": "Play Store", "icon": "storefront", "category": "tools"},
"com.google.android.apps.messaging": {
"name": "Messages",
"icon": "chat",
"category": "communication",
},
# Popular Ecosystem Apps
"com.tencent.mm": {"name": "WeChat", "icon": "forum", "category": "social"},
"com.xingin.xhs": {"name": "Xiaohongshu", "icon": "auto_stories", "category": "social"},
"com.sankuai.meituan": {"name": "Meituan", "icon": "restaurant", "category": "lifestyle"},
"com.dianping.v1": {"name": "Dianping", "icon": "star", "category": "lifestyle"},
"tv.danmaku.bili": {"name": "Bilibili", "icon": "video_library", "category": "entertainment"},
"com.eg.android.AlipayGphone": {
"name": "Alipay",
"icon": "account_balance_wallet",
"category": "finance",
},
"com.netease.cloudmusic": {
"name": "NetEase Music",
"icon": "headphones",
"category": "entertainment",
},
"com.spotify.music": {"name": "Spotify", "icon": "music_note", "category": "entertainment"},
}
# ============================================================================
# CURATED TASK PRESET LIBRARY (1-2 curated tasks per common app)
# ============================================================================
PRESET_TASK_CATALOG: list[TaskPreset] = [
# 1. Google Maps
TaskPreset(
id="maps_coffee",
title="Find Specialty Coffee",
description="Search nearby top-rated cafes in Google Maps",
goal="Open Google Maps, search for top-rated specialty coffee shops nearby, and view the top result details.",
profile="flash",
category="flash",
tag="Maps",
apps=[
AppInfo(
name="Maps",
icon="explore",
pkg="com.google.android.apps.maps",
category="navigation",
)
],
required_packages=["com.google.android.apps.maps"],
priority=95,
),
TaskPreset(
id="pro_commute_share",
title="Commute ETA & Message Draft",
description="Check transit time on Maps and draft arrival ETA in Messages",
goal="Open Google Maps to check commute time to the International Airport, calculate arrival time, then open Messages and draft an ETA text message.",
profile="pro",
category="cross_app",
tag="Maps + Messages",
apps=[
AppInfo(
name="Maps",
icon="explore",
pkg="com.google.android.apps.maps",
category="navigation",
),
AppInfo(
name="Messages",
icon="chat",
pkg="com.google.android.apps.messaging",
category="communication",
),
],
required_packages=["com.google.android.apps.maps", "com.google.android.apps.messaging"],
match_mode="all",
priority=92,
),
# 2. Gmail
TaskPreset(
id="gmail_receipts",
title="Search Order Receipts",
description="Find recent flight or delivery confirmation emails in Gmail",
goal="Open Gmail and search for recent flight or package delivery confirmation emails.",
profile="flash",
category="flash",
tag="Gmail",
apps=[
AppInfo(name="Gmail", icon="mail", pkg="com.google.android.gm", category="productivity")
],
required_packages=["com.google.android.gm"],
priority=90,
),
TaskPreset(
id="pro_email_to_calendar",
title="Email Itinerary to Calendar",
description="Extract flight or event dates from Gmail and schedule in Calendar",
goal="Open Gmail to find the latest event invitation or itinerary, extract dates and location, then open Google Calendar and create a corresponding calendar event.",
profile="pro",
category="cross_app",
tag="Gmail + Calendar",
apps=[
AppInfo(
name="Gmail", icon="mail", pkg="com.google.android.gm", category="productivity"
),
AppInfo(
name="Calendar",
icon="calendar_month",
pkg="com.google.android.calendar",
category="productivity",
),
],
required_packages=["com.google.android.gm"],
priority=94,
),
# 3. Chrome
TaskPreset(
id="chrome_research",
title="Search AI News Breakthroughs",
description="Search latest multimodal AI developments in Chrome browser",
goal="Open Chrome browser and search for latest breakthroughs in multimodal mobile AI agents.",
profile="flash",
category="flash",
tag="Chrome",
apps=[AppInfo(name="Chrome", icon="public", pkg="com.android.chrome", category="browser")],
required_packages=["com.android.chrome"],
priority=88,
),
TaskPreset(
id="pro_research_keep",
title="Product Research & Notes Note",
description="Compare top 3 headphones on Chrome and record comparison in Keep",
goal="Open Chrome, research top 3 noise-cancelling headphones comparing price and battery life, then write a structured comparison summary note in Keep Notes.",
profile="pro",
category="pro",
tag="Chrome + Keep",
apps=[
AppInfo(name="Chrome", icon="public", pkg="com.android.chrome", category="browser"),
AppInfo(
name="Keep Notes",
icon="note_alt",
pkg="com.google.android.keep",
category="productivity",
),
],
required_packages=["com.android.chrome"],
priority=91,
),
# 4. YouTube
TaskPreset(
id="youtube_lofi",
title="Play Lo-Fi Music Radio",
description="Search and play a Lo-Fi hip hop live stream on YouTube",
goal='Open YouTube, search for "Lofi hip hop beats relaxing radio" and tap on the live stream.',
profile="flash",
category="flash",
tag="YouTube",
apps=[
AppInfo(
name="YouTube",
icon="smart_display",
pkg="com.google.android.youtube",
category="entertainment",
)
],
required_packages=["com.google.android.youtube"],
priority=85,
),
# 5. Settings
TaskPreset(
id="settings_display_wifi",
title="Dark Mode & Wi-Fi Check",
description="Toggle dark theme and verify network connection in Settings",
goal="Open Settings app, navigate to Display settings, ensure Dark theme is enabled, and check Wi-Fi connection status.",
profile="flash",
category="flash",
tag="Settings",
apps=[
AppInfo(name="Settings", icon="settings", pkg="com.android.settings", category="system")
],
required_packages=["com.android.settings"],
priority=87,
),
TaskPreset(
id="pro_settings_qa",
title="Subsystem Health & Crash Probe",
description="Traverse Settings submenus to verify screens and check for crash dialogs",
goal="Explore Settings submenus (Network, Connected devices, Apps, Battery, Storage), verify each screen loads properly without ANR or crash dialogs, and summarize results.",
profile="pro",
category="monitor",
tag="Settings QA",
apps=[
AppInfo(name="Settings", icon="settings", pkg="com.android.settings", category="system")
],
required_packages=["com.android.settings"],
priority=93,
),
# 6. Clock
TaskPreset(
id="clock_timer",
title="25-Min Pomodoro Timer",
description="Start a 25-minute focus countdown timer in Clock app",
goal="Open Clock app, switch to Timer tab, set 25 minutes and start the countdown timer.",
profile="flash",
category="flash",
tag="Clock",
apps=[
AppInfo(
name="Clock", icon="timer", pkg="com.google.android.deskclock", category="utility"
)
],
required_packages=["com.google.android.deskclock", "com.android.deskclock"],
priority=86,
),
# 7. Calculator
TaskPreset(
id="calc_gratuity",
title="Split Bill & Calculate Tip",
description="Calculate 18% gratuity on $186.40 for 3 people in Calculator",
goal="Open Calculator and calculate 18% tip on a bill of $186.40, then divide by 3 people.",
profile="flash",
category="flash",
tag="Calculator",
apps=[
AppInfo(
name="Calculator",
icon="calculate",
pkg="com.google.android.calculator",
category="utility",
)
],
required_packages=["com.google.android.calculator", "com.android.calculator2"],
priority=84,
),
# 8. Photos
TaskPreset(
id="photos_inspect",
title="Inspect Recent Screenshot",
description="Open Google Photos and review the latest screenshot taken",
goal="Open Google Photos and view the most recent screenshot in the screenshots album.",
profile="flash",
category="flash",
tag="Photos",
apps=[
AppInfo(
name="Photos",
icon="photo_library",
pkg="com.google.android.apps.photos",
category="media",
)
],
required_packages=["com.google.android.apps.photos"],
priority=82,
),
# 9. WeChat (微信)
TaskPreset(
id="wechat_browse",
title="Check WeChat Messages",
description="Open WeChat and view top recent chat conversations",
goal="Open WeChat (微信) and view the top recent chat messages.",
profile="flash",
category="flash",
tag="WeChat",
apps=[AppInfo(name="WeChat", icon="forum", pkg="com.tencent.mm", category="social")],
required_packages=["com.tencent.mm"],
priority=89,
),
TaskPreset(
id="pro_wechat_to_calendar",
title="WeChat Notice to Calendar",
description="Extract meeting notice from WeChat chat and add to Calendar",
goal="Open WeChat (微信), locate the latest meeting announcement or event message in the top chat, extract the time and topic, then open Calendar and schedule an event.",
profile="pro",
category="cross_app",
tag="WeChat + Calendar",
apps=[
AppInfo(name="WeChat", icon="forum", pkg="com.tencent.mm", category="social"),
AppInfo(
name="Calendar",
icon="calendar_month",
pkg="com.google.android.calendar",
category="productivity",
),
],
required_packages=["com.tencent.mm"],
priority=93,
),
# 10. Xiaohongshu (小红书)
TaskPreset(
id="xhs_coffee_guide",
title="RED Cafe Guide Search",
description="Search trending specialty cafe reviews on Xiaohongshu",
goal="Open Xiaohongshu (小红书), search for top-rated specialty coffee shops, and view the top post.",
profile="flash",
category="flash",
tag="Xiaohongshu",
apps=[
AppInfo(
name="Xiaohongshu", icon="auto_stories", pkg="com.xingin.xhs", category="social"
)
],
required_packages=["com.xingin.xhs"],
priority=87,
),
# 11. Meituan / Dianping (美团 / 大众点评)
TaskPreset(
id="meituan_ramen_search",
title="Meituan Food Search",
description="Search top-rated Ramen nearby on Meituan or Dianping",
goal="Open Meituan (美团) or Dianping (大众点评), search for top-rated Ramen nearby, and view top restaurant rating.",
profile="flash",
category="flash",
tag="Meituan",
apps=[
AppInfo(
name="Meituan", icon="restaurant", pkg="com.sankuai.meituan", category="lifestyle"
)
],
required_packages=["com.sankuai.meituan", "com.dianping.v1"],
priority=86,
),
# 12. Bilibili (哔哩哔哩)
TaskPreset(
id="bilibili_stream",
title="Bilibili Tech Video",
description="Search and play an AI Agent tutorial video on Bilibili",
goal='Open Bilibili (哔哩哔哩), search for "AI Agent 架构实战", and play the top matching video.',
profile="flash",
category="flash",
tag="Bilibili",
apps=[
AppInfo(
name="Bilibili",
icon="video_library",
pkg="tv.danmaku.bili",
category="entertainment",
)
],
required_packages=["tv.danmaku.bili"],
priority=85,
),
# 13. Play Store
TaskPreset(
id="pro_playstore_review",
title="Play Store App Review Study",
description="Compare top task management apps and ratings on Google Play",
goal="Open Google Play Store, search for top rated task management apps, compare ratings and latest user reviews of the top 2 candidates, and record recommendations.",
profile="pro",
category="pro",
tag="Play Store",
apps=[
AppInfo(
name="Play Store", icon="storefront", pkg="com.android.vending", category="tools"
)
],
required_packages=["com.android.vending"],
priority=88,
),
]
# ============================================================================
# RECOMMENDATION ENGINE
# ============================================================================
class TaskRecommendationEngine:
"""Intelligent recommendation engine matching device capabilities."""
def __init__(self, catalog: list[TaskPreset] | None = None):
self.catalog = catalog or PRESET_TASK_CATALOG
def get_all_tasks(self) -> list[dict[str, Any]]:
return [task.model_dump() for task in self.catalog]
def recommend_tasks(
self, installed_packages: list[str] | set[str], category: str = "all", limit: int = 12
) -> list[dict[str, Any]]:
pkgs_set = (
set(installed_packages) if isinstance(installed_packages, list) else installed_packages
)
scored_tasks: list[tuple[int, TaskPreset, bool]] = []
for task in self.catalog:
# Check package presence
is_matched = False
if pkgs_set:
if not task.required_packages:
is_matched = True
elif task.match_mode == "all":
is_matched = all(pkg in pkgs_set for pkg in task.required_packages)
else:
is_matched = any(pkg in pkgs_set for pkg in task.required_packages)
else:
is_matched = False
# Calculate score
score = task.priority
if pkgs_set:
if is_matched:
score += 100
if len(task.required_packages) > 1 and task.match_mode == "all":
score += 30
else:
score -= 40
# Filter by category
if category == "flash" and task.profile != "flash":
continue
elif category == "pro" and task.profile != "pro":
continue
elif category == "cross_app" and task.category != "cross_app":
continue
elif category == "monitor" and task.category != "monitor":
continue
scored_tasks.append((score, task, is_matched))
# Sort descending by score
scored_tasks.sort(key=lambda item: item[0], reverse=True)
results: list[dict[str, Any]] = []
for _, task, matched in scored_tasks[:limit]:
d = task.model_dump()
d["is_device_matched"] = matched
results.append(d)
return results
task_recommendation_engine = TaskRecommendationEngine()
+254 -65
View File
@@ -16,7 +16,6 @@ import asyncio
from datetime import datetime
import os
import shutil
import signal
import sys
import time
from typing import Any
@@ -31,6 +30,7 @@ try:
)
from admin_console.core.state import state
from admin_console.database.repositories.session_repository import session_repo
from artemis.runtime import process_supervisor
except ImportError:
from apps.admin_console.core.config import (
PAUSE_FILE,
@@ -40,6 +40,7 @@ except ImportError:
)
from apps.admin_console.core.state import state
from apps.admin_console.database.repositories.session_repository import session_repo
from artemis.runtime import process_supervisor
class TaskQueueService:
@@ -48,51 +49,114 @@ class TaskQueueService:
"""
@classmethod
async def queue_worker(cls):
while True:
goal = None
sess_id = None
def _broadcast_event(cls, event_type: str, data: Any):
"""Broadcasts an event safely to all registered subscribers."""
for cb in list(state.ipc_subscribers):
try:
item = await state.task_queue.get()
if isinstance(item, dict):
goal = item.get("goal")
profile = item.get("profile", "flash")
sess_id = item.get("session_id")
else:
goal = item
profile = "flash"
sess_id = None
cb(event_type, data)
except Exception:
pass
@classmethod
def _get_next_pending_task(cls) -> dict[str, Any] | None:
"""Finds and returns the first pending task from queue_items."""
for item in state.queue_items:
if isinstance(item, dict) and item.get("status") == "pending":
return item
return None
@classmethod
def _remove_task(cls, session_id: str | None):
"""Removes a task from queue_items by session_id."""
if not session_id:
return
state.queue_items = [
t
for t in state.queue_items
if not (isinstance(t, dict) and t.get("session_id") == session_id)
]
@classmethod
def ensure_worker_running(cls):
"""Guarantees that the background queue worker task is active and running."""
try:
loop = asyncio.get_running_loop()
if state.worker_task is None or state.worker_task.done():
state.worker_task = loop.create_task(cls.queue_worker())
try:
state.wake_event.set()
except Exception:
pass
except RuntimeError:
pass
@classmethod
async def queue_worker(cls):
"""Persistent, self-healing background worker loop processing tasks in FIFO order."""
print("[QueueWorker] Background worker loop initialized and running.")
while True:
task_item = None
sess_id = None
goal = None
profile = "flash"
try:
if state.is_running:
await asyncio.sleep(0.2)
continue
# 1. Fetch the next pending task
task_item = cls._get_next_pending_task()
if task_item is None:
await asyncio.sleep(0.3)
continue
# 2. Extract and initialize task execution metadata
sess_id = task_item.get("session_id")
if sess_id and str(sess_id) in getattr(state, "cancelled_session_ids", set()):
cls._remove_task(sess_id)
continue
goal = task_item.get("goal")
profile = task_item.get("profile", "flash")
expected_output = task_item.get("expected_output")
enable_outputter = task_item.get("enable_outputter")
locked_app = task_item.get("locked_app_package") or task_item.get("locked_app")
app_path = task_item.get("app_path")
task_item["status"] = "running"
task_item["start_time"] = time.time()
state.current_goal = goal
state.current_profile = profile
if sess_id:
state.active_session_id = sess_id
state.active_session_id = sess_id
state.was_stopped_manually = False
# Remove from state.queue_tasks
state.queue_tasks = [
t
for t in state.queue_tasks
if not (
isinstance(t, dict)
and (t.get("session_id") == sess_id or t.get("goal") == goal)
)
]
# Broadcast session_started so all connected clients know the task has started
cls._broadcast_event(
"session_started",
{
"session_id": sess_id,
"initial_goal": goal,
"profile": profile,
},
)
test_name = f"web_{int(time.time())}"
env = os.environ.copy()
pythonpath_parts = [
str(WORKSPACE_ROOT),
str(WORKSPACE_ROOT / "apps/admin_console"),
str(WORKSPACE_ROOT / "apps/cloud_service"),
str(WORKSPACE_ROOT / "apps" / "admin_console"),
str(WORKSPACE_ROOT / "apps" / "cloud_service"),
env.get("PYTHONPATH", ""),
]
env["PYTHONPATH"] = os.pathsep.join([p for p in pythonpath_parts if p])
env["PYTHONUTF8"] = "1"
env["PYTHONUNBUFFERED"] = "1"
if state.ipc_port is not None:
env["ARTEMIS_IPC_PORT"] = str(state.ipc_port)
if sess_id:
env["ARTEMIS_SESSION_ID"] = str(sess_id)
print(f"[QueueWorker] Starting task: {goal} with profile: {profile}")
state.current_process = await asyncio.create_subprocess_exec(
cmd = [
sys.executable,
"-m",
"artemis.main",
@@ -101,48 +165,98 @@ class TaskQueueService:
profile,
"--test-name",
test_name,
]
if expected_output:
cmd.extend(["--output-description", str(expected_output)])
if enable_outputter is not None:
cmd.append("--enable-outputter" if enable_outputter else "--disable-outputter")
if locked_app:
cmd.extend(["--locked-app", str(locked_app)])
if app_path:
cmd.extend(["--app-path", str(app_path)])
print(
f"[QueueWorker] Starting task [{sess_id}]: '{goal}' (profile: {profile}, outputter: {bool(expected_output or enable_outputter)})"
)
proc = await asyncio.create_subprocess_exec(
*cmd,
cwd=str(WORKSPACE_ROOT),
env=env,
)
await state.current_process.wait()
print(
f"[QueueWorker] Task finished with code "
f"{state.current_process.returncode}: {goal}"
)
state.current_process = proc
task_item["pid"] = proc.pid
# Fallback status update
if state.active_session_id:
curr_status = session_repo.get_session_status(state.active_session_id)
if curr_status == "running":
if state.was_stopped_manually:
new_status = "cancelled"
else:
new_status = (
"completed" if state.current_process.returncode == 0 else "failed"
)
session_repo.update_session_status(
state.active_session_id, new_status, time.time()
)
print(
f"[QueueWorker] Fallback: Updated session "
f"{state.active_session_id} status to {new_status}"
)
state.active_session_id = None
if sess_id and (
str(sess_id) in getattr(state, "cancelled_session_ids", set())
or state.was_stopped_manually
):
print(
f"[QueueWorker] Task [{sess_id}] was cancelled during launch. Terminating."
)
try:
proc.kill()
except Exception:
pass
state.current_profile = None
state.was_stopped_manually = False
# 3. Await subprocess completion
returncode = await proc.wait()
print(f"[QueueWorker] Task [{sess_id}] exited with returncode {returncode}")
# 4. Perform fallback database status update and notification
if sess_id:
if state.was_stopped_manually:
new_status = "cancelled"
elif returncode == 0:
new_status = "completed"
else:
new_status = "failed"
session_repo.update_session_status(sess_id, new_status, time.time())
state.active_connections.pop(sess_id, None)
print(f"[QueueWorker] Updated session {sess_id} status to '{new_status}'")
cls._broadcast_event(
"session_ended",
{
"session_id": sess_id,
"status": new_status,
"was_stopped_manually": state.was_stopped_manually,
},
)
except asyncio.CancelledError:
print("[QueueWorker] Background worker task was cancelled.")
print("[QueueWorker] Task received cancellation signal.")
if state.current_process and state.current_process.returncode is None:
try:
state.current_process.kill()
except Exception:
pass
break
except Exception as e:
print(f"[QueueWorker] Error executing task: {e}")
print(f"[QueueWorker] Unexpected error executing task: {e}")
await asyncio.sleep(0.5)
finally:
if goal is not None:
state.task_queue.task_done()
# 5. Clean up completed task from queue and reset execution state
if sess_id:
cls._remove_task(sess_id)
state.current_process = None
state.active_session_id = None
state.current_goal = None
state.current_profile = None
state.was_stopped_manually = False
state.wake_event.set()
@classmethod
async def enqueue_tasks(cls, goals: list[str], profile: str = "flash") -> dict[str, Any]:
async def enqueue_tasks(
cls,
goals: list[str],
profile: str = "flash",
expected_output: str | None = None,
enable_outputter: bool | None = None,
locked_app_package: str | None = None,
app_path: str | None = None,
) -> dict[str, Any]:
"""Enqueues one or more goals and wakes up the background worker."""
cls.ensure_worker_running()
enqueued_tasks = []
now = time.time()
for i, goal in enumerate(goals):
@@ -151,14 +265,20 @@ class TaskQueueService:
"session_id": sess_id,
"goal": goal,
"profile": profile or "flash",
"expected_output": expected_output,
"enable_outputter": enable_outputter,
"locked_app_package": locked_app_package,
"app_path": app_path,
"status": "pending",
"created_at": now + i * 0.001,
"start_time": now + i * 0.001,
}
await state.task_queue.put(task_item)
state.queue_tasks.append(task_item)
state.queue_items.append(task_item)
enqueued_tasks.append(task_item)
# Wake worker immediately
state.wake_event.set()
return {
"status": "queued" if state.is_running else "started",
"tasks": enqueued_tasks,
@@ -168,36 +288,105 @@ class TaskQueueService:
@classmethod
def stop_tasks(cls, clear_all: bool = False) -> bool:
"""Stops the currently executing task and optionally clears pending tasks."""
stopped = False
stopped_session_id = state.active_session_id
if not stopped_session_id:
running_item = next(
(
t
for t in state.queue_items
if isinstance(t, dict) and t.get("status") == "running"
),
None,
)
if running_item:
stopped_session_id = running_item.get("session_id")
elif state.queue_items and not clear_all:
stopped_session_id = state.queue_items[0].get("session_id")
if stopped_session_id:
getattr(state, "cancelled_session_ids", set()).add(str(stopped_session_id))
for t in state.queue_items:
if isinstance(t, dict) and t.get("status") == "running" and t.get("session_id"):
getattr(state, "cancelled_session_ids", set()).add(str(t["session_id"]))
if clear_all:
state.clear_queue()
stopped = True
state.was_stopped_manually = True
if state.is_running and state.current_process:
if state.current_process:
pid = getattr(state.current_process, "pid", None)
if pid:
try:
process_supervisor.terminate_tree(pid)
except Exception:
pass
try:
state.current_process.kill()
stopped = True
except Exception:
pass
stopped = True
for _, conn_info in list(state.active_connections.items()):
pid = conn_info.get("pid")
if pid:
try:
os.kill(pid, signal.SIGINT)
process_supervisor.terminate_tree(pid)
stopped = True
except Exception as e:
print(f"Failed to kill external process {pid}: {e}")
# Always reset process and active states cleanly
state.current_process = None
state.active_connections.clear()
session_repo.mark_all_running_cancelled()
# Broadcast session_ended event so all SSE streams and subscribers update immediately
if stopped_session_id:
cls._broadcast_event(
"session_ended",
{
"session_id": stopped_session_id,
"status": "cancelled",
"was_stopped_manually": True,
},
)
stopped = True
state.active_session_id = None
state.current_goal = None
state.current_profile = None
# Ensure any running or stopped task is removed from queue_items so worker_loop is unblocked
if clear_all:
state.queue_items = []
else:
state.queue_items = [
t
for t in state.queue_items
if not (
isinstance(t, dict)
and (
t.get("status") == "running"
or (stopped_session_id and t.get("session_id") == stopped_session_id)
)
)
]
if PAUSE_FILE.exists():
try:
PAUSE_FILE.unlink()
except Exception:
pass
return stopped
# Wake up worker to process the next pending task or transition cleanly
cls.ensure_worker_running()
state.wake_event.set()
return stopped or True
@classmethod
def resume_task(cls) -> bool: