feat: enable remote SDK and resilient device orchestration

This commit is contained in:
somew1nd committed 2026-08-30 00:41:07 -07:00
1 parent 3cc711df51
commit ddfa420945
110 files changed
+14450 -12954

No files matched your search

+114 -57
View File
@@ -18,6 +18,7 @@ import json
import os
from pathlib import Path
import subprocess
import threading
from typing import Any
import urllib.parse
from fastapi import HTTPException
@@ -28,6 +29,12 @@ from artemis.config import IMAGES_DIR, TRACES_PATH, WORKSPACE_ROOT
class MediaService:
"""Service handling media indexing, local file security, payload unwrapping and notes/plans."""
# Conversion results keyed by source path -> (mtime, size, resolved path).
# Failed conversions are cached too (resolved path == source path) so a broken
# file doesn't re-run ffmpeg (up to 45s) on every /api/sessions poll.
_playable_cache: dict[str, tuple[float, int, str]] = {}
_playable_cache_lock = threading.Lock()
@classmethod
def ensure_browser_playable_video(cls, p: Path) -> Path:
"""Ensure the video file is in a browser-supported container (MP4).
@@ -41,59 +48,88 @@ class MediaService:
if suffix == ".mp4":
return p
if suffix in (".mkv", ".webm"):
src_stat = p.stat()
cache_key = str(p)
cached = cls._playable_cache.get(cache_key)
if cached and cached[0] == src_stat.st_mtime and cached[1] == src_stat.st_size:
return Path(cached[2])
mp4_cand = p.with_suffix(".mp4")
if mp4_cand.exists() and mp4_cand.stat().st_size > 0:
return mp4_cand
from artemis.utils.video import get_ffmpeg_path
ffmpeg = get_ffmpeg_path()
# 1. Attempt fast copy remux
subprocess.run(
[
ffmpeg,
"-y",
"-fflags",
"+genpts",
"-i",
str(p),
"-c",
"copy",
"-movflags",
"+faststart",
cls._playable_cache[cache_key] = (
src_stat.st_mtime,
src_stat.st_size,
str(mp4_cand),
],
capture_output=True,
timeout=15,
)
if mp4_cand.exists() and mp4_cand.stat().st_size > 0:
return mp4_cand
# 2. If copy remux failed, fallback to ultrafast re-encode
subprocess.run(
[
ffmpeg,
"-y",
"-fflags",
"+genpts+discardcorrupt",
"-i",
str(p),
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-pix_fmt",
"yuv420p",
"-movflags",
"+faststart",
str(mp4_cand),
],
capture_output=True,
timeout=30,
)
if mp4_cand.exists() and mp4_cand.stat().st_size > 0:
)
return mp4_cand
with cls._playable_cache_lock:
# Another thread may have converted this file while we waited.
cached = cls._playable_cache.get(cache_key)
if cached and cached[0] == src_stat.st_mtime and cached[1] == src_stat.st_size:
return Path(cached[2])
try:
result = cls._convert_to_mp4(p, mp4_cand)
except Exception:
result = p
cls._playable_cache[cache_key] = (
src_stat.st_mtime,
src_stat.st_size,
str(result),
)
return result
except Exception:
pass
return p
@staticmethod
def _convert_to_mp4(p: Path, mp4_cand: Path) -> Path:
from artemis.utils.video import get_ffmpeg_path
ffmpeg = get_ffmpeg_path()
# 1. Attempt fast copy remux
subprocess.run(
[
ffmpeg,
"-y",
"-fflags",
"+genpts",
"-i",
str(p),
"-c",
"copy",
"-movflags",
"+faststart",
str(mp4_cand),
],
capture_output=True,
timeout=15,
)
if mp4_cand.exists() and mp4_cand.stat().st_size > 0:
return mp4_cand
# 2. If copy remux failed, fallback to ultrafast re-encode
subprocess.run(
[
ffmpeg,
"-y",
"-fflags",
"+genpts+discardcorrupt",
"-i",
str(p),
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-pix_fmt",
"yuv420p",
"-movflags",
"+faststart",
str(mp4_cand),
],
capture_output=True,
timeout=30,
)
if mp4_cand.exists() and mp4_cand.stat().st_size > 0:
return mp4_cand
return p
@staticmethod
def path_to_video_url(p: Path) -> str:
resolved = p.resolve()
@@ -287,30 +323,51 @@ class MediaService:
return [MediaService.unwrap_payload(x) for x in obj]
return obj
@staticmethod
def get_safe_local_file(path_str: str) -> tuple[Path, str]:
_LOCAL_FILE_MEDIA_TYPES = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
".mp4": "video/mp4",
".webm": "video/webm",
".mkv": "video/x-matroska",
}
@classmethod
def get_safe_local_file(cls, path_str: str) -> tuple[Path, str]:
"""Serve trace media only: resolved path must sit inside the workspace
or traces tree AND carry a media extension, so this endpoint can never
hand out .env, databases, or source files."""
decoded_path = urllib.parse.unquote(path_str)
if decoded_path.startswith("file://"):
decoded_path = decoded_path[len("file://") :]
p = Path(decoded_path).resolve()
try:
p = Path(decoded_path).resolve(strict=True)
except (OSError, ValueError):
raise HTTPException(status_code=404, detail="File not found")
if not p.is_file():
raise HTTPException(status_code=404, detail="File not found")
if not str(p).startswith(str(WORKSPACE_ROOT)):
allowed_roots = []
for root in (WORKSPACE_ROOT, TRACES_PATH):
try:
allowed_roots.append(Path(root).resolve())
except OSError:
continue
if not any(p.is_relative_to(root) for root in allowed_roots):
raise HTTPException(
status_code=403,
detail="Access denied. Path is outside workspace root.",
)
ext = p.suffix.lower()
media_type = "application/octet-stream"
if ext in (".jpg", ".jpeg"):
media_type = "image/jpeg"
elif ext == ".png":
media_type = "image/png"
elif ext == ".mp4":
media_type = "video/mp4"
media_type = cls._LOCAL_FILE_MEDIA_TYPES.get(p.suffix.lower())
if media_type is None:
raise HTTPException(
status_code=403,
detail="Access denied. Only media files are served.",
)
return p, media_type
+22 -5
View File
@@ -13,18 +13,27 @@
# limitations under the License.
import json
import time
from typing import Any
class ModelService:
"""Service handling agent architecture profile detection and metadata formatting."""
@staticmethod
def get_active_model_info(profile: str | None = None) -> dict[str, str]:
"""Return active architecture and underlying LLM model configuration."""
# 1. Determine underlying LLM model and provider from config
model_id = "gemini-3.7-flash"
# parse_llm_config re-reads and re-validates the config file on every call;
# /api/sessions calls this once per session row, so cache (provider, model_id)
# for a short TTL. Config edits take effect within _LLM_INFO_TTL seconds.
_LLM_INFO_TTL = 10.0
_llm_info_cache: tuple[float, str, str] | None = None
@classmethod
def _get_llm_provider_and_model(cls) -> tuple[str, str]:
cached = cls._llm_info_cache
now = time.monotonic()
if cached and now - cached[0] < cls._LLM_INFO_TTL:
return cached[1], cached[2]
provider = "google"
model_id = "gemini-3.7-flash"
try:
from artemis.config import parse_llm_config
@@ -37,6 +46,14 @@ class ModelService:
model_id = str(llm_cfg.default.model)
except Exception:
pass
cls._llm_info_cache = (now, provider, model_id)
return provider, model_id
@classmethod
def get_active_model_info(cls, profile: str | None = None) -> dict[str, str]:
"""Return active architecture and underlying LLM model configuration."""
# 1. Determine underlying LLM model and provider from config (cached)
provider, model_id = cls._get_llm_provider_and_model()
# 2. Determine agent architecture name (Flash vs Pro)
arch_name = "Flash"
@@ -16,6 +16,7 @@ import asyncio
import codecs
from datetime import datetime
import os
import psutil
import shutil
import subprocess
import sys
@@ -38,7 +39,13 @@ from artemis.config import (
TEST_OUTPUTS_DIR,
WORKSPACE_ROOT,
)
from artemis.runtime import DeviceExecutionLock, process_supervisor
from artemis.runtime import (
AdbEndpoint,
AdbTarget,
DeviceExecutionLock,
current_adb_endpoint,
process_supervisor,
)
class TaskQueueService:
@@ -50,6 +57,17 @@ class TaskQueueService:
# keeps weak references to running tasks).
_run_tasks: set[asyncio.Task] = set()
@staticmethod
def _task_target(task_item: dict[str, Any]) -> AdbTarget:
endpoint_data = task_item.get("adb_endpoint")
endpoint = (
AdbEndpoint.from_mapping(endpoint_data)
if isinstance(endpoint_data, dict)
else current_adb_endpoint()
)
serial = task_item.get("device_serial")
return AdbTarget(endpoint=endpoint, serial=str(serial) if serial else None)
@classmethod
def _broadcast_event(cls, event_type: str, data: Any):
"""Broadcasts an event safely to all registered subscribers."""
@@ -241,8 +259,6 @@ class TaskQueueService:
pid = getattr(proc, "pid", None)
if pid:
try:
import psutil
p = psutil.Process(pid)
if not p.is_running() or p.status() == psutil.STATUS_ZOMBIE:
return proc.returncode if proc.returncode is not None else -15
@@ -345,7 +361,7 @@ class TaskQueueService:
if isinstance(i, dict) and i.get("status") == "running"
]
busy_devices = state.busy_device_ids | {
str(i.get("device_serial")) for i in in_flight if i.get("device_serial")
cls._task_target(i).lock_key for i in in_flight if i.get("device_serial")
}
dispatched_any = False
loop = asyncio.get_running_loop()
@@ -358,19 +374,20 @@ class TaskQueueService:
continue
device = item.get("device_serial")
target = cls._task_target(item)
if mode == "per_device":
# A task without a resolved device may bind to any serial, so it
# only launches on an otherwise idle scheduler; the device lock
# then allocates freely without contending against active runs.
if device is None and (state.active_runs or in_flight or dispatched_any):
continue
if device is not None and str(device) in busy_devices:
if device is not None and target.lock_key in busy_devices:
continue
item["status"] = "running"
dispatched_any = True
if device is not None:
busy_devices.add(str(device))
busy_devices.add(target.lock_key)
run_task = loop.create_task(cls._execute_task_item(item))
# Hold a strong reference: asyncio keeps only weak refs to running
# tasks, and a collected run would strand its queue item forever.
@@ -389,6 +406,8 @@ class TaskQueueService:
proc: asyncio.subprocess.Process | None = None
output_task: asyncio.Task[None] | None = None
try:
if not isinstance(goal, str) or not goal.strip():
raise ValueError("Queued task must contain a non-empty string goal.")
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")
@@ -436,6 +455,9 @@ class TaskQueueService:
env["ARTEMIS_SESSION_ID"] = str(sess_id)
env["ARTEMIS_TASK_INGRESS"] = str(task_item.get("ingress", "frontend"))
env["ARTEMIS_TASK_WORKER"] = "1"
target = cls._task_target(task_item)
target.endpoint.apply_to_environment(env)
env[DeviceExecutionLock.LOCK_SCOPE_ENV] = target.lock_scope
queue_ticket = task_item.get("queue_ticket")
if queue_ticket:
env[DeviceExecutionLock.QUEUE_TICKET_ENV] = str(queue_ticket)
@@ -479,6 +501,8 @@ class TaskQueueService:
state.active_runs[run_key] = {
"process": proc,
"device_id": str(device_serial) if device_serial else None,
"lock_key": target.lock_key if device_serial else None,
"adb_endpoint": target.endpoint.to_dict(),
"goal": goal,
"profile": profile,
}
@@ -503,6 +527,7 @@ class TaskQueueService:
device_id=device_serial or "pending",
session_id=str(sess_id) if sess_id else None,
ingress=ingress_type,
lock_scope=target.lock_scope,
)
# Forward the worker's combined output and tee it into the trace's
# stdout.log so the log paths advertised by the MCP API exist.
@@ -667,6 +692,7 @@ class TaskQueueService:
enqueued_tasks = []
now = time.time()
endpoint = current_adb_endpoint()
# 1. Deduplication by session_id: if session_id is already running or queued, do not re-enqueue
if session_id and len(goals) == 1:
@@ -705,6 +731,7 @@ class TaskQueueService:
and item.get("status") == "pending"
and item.get("goal") == first_goal
and (not device_serial or item.get("device_serial") == device_serial)
and item.get("adb_endpoint", {}).get("identity") == endpoint.identity
and (now - float(item.get("created_at", 0))) < 1.0
),
None,
@@ -719,31 +746,19 @@ class TaskQueueService:
# Strict device binding: reject an explicitly requested serial that is not
# attached and authorized, instead of silently running on another device.
# The shared validator fails open on an indeterminate/empty enumeration so
# the task can proceed and fail downstream with a clear no-device error.
if device_serial:
try:
from artemis.runtime import device_pool
norm = DeviceExecutionLock._normalize_device_id
attached = {
norm(d.serial): d.state
for d in await device_pool.list_devices_async()
}
rejection = await device_pool.validate_explicit_serial_async(device_serial)
except Exception:
attached = None
# Empty enumeration (no adb / transient failure) skips validation so the
# task can proceed and fail downstream with a clear no-device error,
# mirroring mcp_server.tools.task_runner._validate_device_serial.
if attached and attached.get(norm(device_serial)) != "device":
dev_state = attached.get(norm(device_serial))
error_detail = (
f"Device '{device_serial}' is not connected. "
f"Attached devices: {sorted(attached) if attached else 'none'}."
if dev_state is None
else f"Device '{device_serial}' is attached but not ready (state: '{dev_state}')."
)
rejection = None
if rejection:
return {
"status": "rejected",
"error": error_detail,
"error": rejection,
"tasks": [],
"enqueued_count": 0,
"total_queued": len(state.queue_tasks),
@@ -772,6 +787,7 @@ class TaskQueueService:
device_id=assigned_serial or "pending",
session_id=sess_id,
ingress=ingress,
lock_scope=endpoint.identity,
)
task_item = {
"session_id": sess_id,
@@ -782,6 +798,7 @@ class TaskQueueService:
"locked_app_package": locked_app_package,
"app_path": app_path,
"device_serial": assigned_serial,
"adb_endpoint": endpoint.to_dict(),
"ingress": ingress,
"conversation_id": conversation_id,
"status": "pending",