676 lines
30 KiB
Python
676 lines
30 KiB
Python
# 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.
|
|
|
|
"""MCP Tool: mobile_run_task."""
|
|
|
|
import logging
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
from typing import Any, get_args
|
|
import uuid
|
|
|
|
from mcp_server.base import mcp
|
|
from mcp_server.notifiers import notify
|
|
from mcp_server.utils import env_utils
|
|
from artemis.config import ExplorerVersion, checker_overrides_for_level
|
|
from artemis.config.runtime import read_ipc_port
|
|
from artemis.runtime import (
|
|
DeviceExecutionLock,
|
|
device_pool,
|
|
ensure_daemon_running,
|
|
submit_task_to_daemon,
|
|
trace_store,
|
|
)
|
|
from artemis.runtime.task_limits import ExecutionBudget, task_timeout
|
|
from artemis.runtime.task_control import read_state
|
|
from artemis.runtime.task_result import save_failure_result, read_worker_outcome
|
|
from artemis.config.settings import settings
|
|
|
|
# Seconds the spawned runner gets to finish its imports and open its log files
|
|
# before the spawn is declared dead. Normal startup creates stdout.log within a
|
|
# few seconds; a runner that produced no logs by this deadline is hung in
|
|
# interpreter/DLL startup and will never recover (observed on Windows when the
|
|
# MCP server process context degrades).
|
|
SPAWN_WATCHDOG_SECONDS = 60
|
|
|
|
|
|
def _watch_execution(proc, trace_id: str, limit: int, cleanup_limit: float | None = None) -> None:
|
|
"""Supervise standalone MCP workers without charging device queue or pause time."""
|
|
budget = ExecutionBudget(limit)
|
|
finalized_at = None
|
|
cleanup_limit = cleanup_limit or settings.ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS
|
|
while proc.poll() is None:
|
|
status = trace_store.read_status(trace_id) or {}
|
|
outcome = read_worker_outcome(trace_id)
|
|
if outcome and status.get("status") not in {
|
|
"completed",
|
|
"success",
|
|
"failed",
|
|
"cancelled",
|
|
"interrupted",
|
|
}:
|
|
try:
|
|
trace_store.update_trace_status(
|
|
trace_id, outcome["task_status"], error=outcome.get("error")
|
|
)
|
|
except OSError:
|
|
logging.exception("Cannot persist terminal status for %s", trace_id)
|
|
if outcome or status.get("status") in {
|
|
"completed",
|
|
"success",
|
|
"failed",
|
|
"cancelled",
|
|
"interrupted",
|
|
}:
|
|
finalized_at = finalized_at if finalized_at is not None else time.monotonic()
|
|
if time.monotonic() - finalized_at >= cleanup_limit:
|
|
_kill_process_tree(proc.pid)
|
|
return
|
|
time.sleep(0.5)
|
|
continue
|
|
control_status = read_state(trace_id).get("status")
|
|
remaining = budget.update(excluded=control_status in {"queued", "task_paused", "resuming"})
|
|
if remaining <= 0:
|
|
error = f"任务执行超时({limit} 秒,不含暂停/排队时间)"
|
|
_kill_process_tree(proc.pid)
|
|
try:
|
|
save_failure_result(trace_id, error, code="task_timeout")
|
|
trace_store.update_trace_status(trace_id, "failed", error=error)
|
|
except OSError:
|
|
logging.exception("Cannot persist timeout report for %s", trace_id)
|
|
return
|
|
time.sleep(0.5)
|
|
|
|
|
|
def _kill_process_tree(pid: int) -> None:
|
|
"""Terminate a spawned runner and its children (the venv shim re-execs python)."""
|
|
if sys.platform == "win32":
|
|
subprocess.run(
|
|
["taskkill", "/PID", str(pid), "/T", "/F"],
|
|
stdin=subprocess.DEVNULL,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
check=False,
|
|
)
|
|
else:
|
|
try:
|
|
os.kill(pid, 9)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def _watch_spawn(
|
|
trace_id: str,
|
|
pid: int,
|
|
queue_ticket: str,
|
|
conversation_id: str | None,
|
|
deadline_seconds: float = SPAWN_WATCHDOG_SECONDS,
|
|
poll_interval: float = 2.0,
|
|
) -> bool:
|
|
"""Watch a freshly spawned runner; kill and fail the task if it never boots.
|
|
|
|
The runner opens stdout.log/stderr.log immediately after module imports, so
|
|
their absence after the deadline means the process is wedged before running
|
|
any task code. Without this, a wedged spawn leaves the task in 'running'
|
|
forever and holds its device-queue ticket.
|
|
|
|
Returns True when the runner booted (or was already terminal), False when it
|
|
was declared hung and killed.
|
|
"""
|
|
trace_dir = trace_store.get_trace_dir(trace_id)
|
|
log_paths = (
|
|
os.path.join(trace_dir, "stdout.log"),
|
|
os.path.join(trace_dir, "stderr.log"),
|
|
)
|
|
|
|
deadline = time.monotonic() + deadline_seconds
|
|
while time.monotonic() < deadline:
|
|
if any(os.path.exists(p) for p in log_paths):
|
|
return True # runner booted normally
|
|
status_data = trace_store.read_status(trace_id)
|
|
if status_data and status_data.get("status") not in ("running", "pending"):
|
|
return True # already terminal (e.g. stopped by the user)
|
|
time.sleep(poll_interval)
|
|
|
|
if any(os.path.exists(p) for p in log_paths):
|
|
return True
|
|
status_data = trace_store.read_status(trace_id)
|
|
if status_data and status_data.get("status") not in ("running", "pending"):
|
|
return True
|
|
|
|
error_text = (
|
|
f"Background runner (pid {pid}) produced no logs within "
|
|
f"{deadline_seconds:.0f}s of spawn and was killed: the process hung "
|
|
"during interpreter startup. Restarting the MCP server usually clears "
|
|
"this; the task can be resubmitted afterwards."
|
|
)
|
|
_kill_process_tree(pid)
|
|
DeviceExecutionLock.cancel_reservation(queue_ticket)
|
|
trace_store.update_trace_status(trace_id, "failed", error=error_text)
|
|
if conversation_id:
|
|
try:
|
|
notify(
|
|
conversation_id=conversation_id,
|
|
message=f"Artemis task '{trace_id}' failed to start: {error_text}",
|
|
event_type="failed",
|
|
payload={"trace_id": trace_id, "error": error_text},
|
|
)
|
|
except Exception as exc:
|
|
# Notification dispatch is best-effort; the failure verdict is
|
|
# already persisted in status.json.
|
|
logging.getLogger("mcp_server").debug(
|
|
"Spawn-failure notification skipped for trace %s: %s",
|
|
trace_id,
|
|
exc,
|
|
exc_info=True,
|
|
)
|
|
return False
|
|
|
|
|
|
def _start_spawn_watchdog(
|
|
trace_id: str,
|
|
pid: int,
|
|
queue_ticket: str,
|
|
conversation_id: str | None,
|
|
) -> None:
|
|
"""Run _watch_spawn on a daemon thread so the tool call returns immediately."""
|
|
threading.Thread(
|
|
target=_watch_spawn,
|
|
args=(trace_id, pid, queue_ticket, conversation_id),
|
|
name=f"spawn-watchdog-{trace_id[:8]}",
|
|
daemon=True,
|
|
).start()
|
|
|
|
|
|
def _validate_device_serial(device_serial: str) -> dict[str, Any] | None:
|
|
"""Reject a task whose explicitly requested device is not attached and authorized.
|
|
|
|
Returns a failure response dict when the serial must be rejected, or None when
|
|
the device is usable. If device enumeration itself fails (no adb available),
|
|
validation is skipped rather than blocking task submission.
|
|
"""
|
|
try:
|
|
# The shared validator fails open on an indeterminate/empty enumeration:
|
|
# the task proceeds and fails downstream with a clear no-device error.
|
|
detail = device_pool.validate_explicit_serial(device_serial)
|
|
except Exception:
|
|
return None
|
|
if detail is None:
|
|
return None
|
|
return {
|
|
"status": "failed",
|
|
"error": detail,
|
|
"message": (
|
|
f"{detail} Task rejected to prevent execution on an unintended device. "
|
|
"Run `adb devices -l` to inspect attached hardware, then resubmit with a "
|
|
"valid serial (or omit device_serial for automatic selection)."
|
|
),
|
|
}
|
|
|
|
|
|
_EXPLORER_MODES: tuple[str, ...] = get_args(ExplorerVersion)
|
|
|
|
|
|
def _normalize_pro_tuning(
|
|
verification_level: str | None, explorer_mode: str | None
|
|
) -> tuple[str | None, str | None]:
|
|
"""Validate and normalise the Pro-profile tuning knobs (strip + lower).
|
|
|
|
Raises:
|
|
ValueError: with a caller-facing message when a value is not a known
|
|
preset, so the tool rejects the call before any trace is created.
|
|
"""
|
|
level: str | None = None
|
|
if verification_level is not None and str(verification_level).strip():
|
|
level = str(verification_level).strip().lower()
|
|
try:
|
|
checker_overrides_for_level(level)
|
|
except ValueError as exc:
|
|
raise ValueError(f"Invalid verification_level: {exc}") from exc
|
|
|
|
mode: str | None = None
|
|
if explorer_mode is not None and str(explorer_mode).strip():
|
|
mode = str(explorer_mode).strip().lower()
|
|
if mode not in _EXPLORER_MODES:
|
|
raise ValueError(
|
|
f"Invalid explorer_mode {explorer_mode!r}. Must be one of: "
|
|
+ ", ".join(_EXPLORER_MODES)
|
|
)
|
|
return level, mode
|
|
|
|
|
|
@mcp.tool()
|
|
def mobile_run_task(
|
|
task_desc: str,
|
|
conversation_id: str | None = None,
|
|
model: str = "Flash",
|
|
locked_app_package: str | None = None,
|
|
app_path: str | None = None,
|
|
expected_output_desc: str | None = None,
|
|
device_serial: str | None = None,
|
|
verification_level: str | None = None,
|
|
explorer_mode: str | None = None,
|
|
runtime_overrides: dict[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Starts an autonomous mobile UI automation subagent on a connected Android device.
|
|
|
|
Delegates a mobile workflow to a background agent. Non-blocking: returns
|
|
immediately with `trace_id` (for `mobile_manage_task` / `mobile_inspect_trace`),
|
|
`device_serial`, `stdout_log`/`stderr_log` paths, and `notes_dir` (Pro only).
|
|
On completion (success or failure) a Reactive Wakeup notifies you — but only
|
|
when `conversation_id` was provided; without it, rely on your fallback timer.
|
|
|
|
### Model selection (routing)
|
|
- **Flash** (default, preferred): simple, deterministic tasks with a clear
|
|
UI path. No step cap by default (history is compressed, and the agent
|
|
can recall earlier steps and query the session recording), but no ADB
|
|
shell, no pre-execution safety net, no persistent plan or notes, and
|
|
no midway checkpoints or persistent written notes. An exit-only read-only
|
|
Checker judges the prompt's expectations and returns test_result;
|
|
execution completion is separate from test pass. Unsuitable for exploration,
|
|
multi-branch recovery, structured monitoring/polling, or tasks that
|
|
must report back large amounts of detail.
|
|
- **Pro**: complex, exploratory, or multi-branch tasks; continuous
|
|
monitoring/polling; tasks needing ADB shell, system logs, verified
|
|
checkpoints, multi-step planning, or detailed written output (via notes
|
|
and `expected_output_desc`).
|
|
|
|
Timing is not precise: the agent's own inference adds ~5 s per step (Flash)
|
|
or ~30 s per turn (Pro) on top of any requested waits — account for this
|
|
when the task involves waiting. For recurring workflows, run once to
|
|
discover the path, then author a deterministic script instead of
|
|
re-delegating.
|
|
|
|
### Follow-up protocol (CRITICAL)
|
|
1. **Fallback timer**: you MUST set a background timer and check the task
|
|
status via `mobile_manage_task` at least once every 1 minute — mobile
|
|
tasks can stall silently, so never rely on the completion wakeup alone.
|
|
2. **Post-task inspection**: after completion, inspect the execution logs
|
|
to verify success (`stderr_log` carries critical failures and Python
|
|
tracebacks — essential for debugging).
|
|
3. **Pro notes**: for Pro runs, read `notes_dir` to extract the results and
|
|
plans the subagent recorded.
|
|
|
|
Args:
|
|
task_desc: Clear, self-contained task description with all context,
|
|
exact UI actions, and termination conditions (execution is one-shot).
|
|
For Pro, may also specify what the subagent must record in its notes.
|
|
conversation_id: Optional conversation ID used to route the completion
|
|
wakeup notification back to the caller. Omit if unknown.
|
|
model: `"Flash"` or `"Pro"` — see model selection above.
|
|
locked_app_package: Optional package name to lock execution to; the
|
|
agent auto-launches it and restricts actions to that app.
|
|
app_path: Optional local APK path to install before running.
|
|
expected_output_desc: Optional, Pro only. If set, a summarization agent
|
|
writes a report to `output.md` in `notes_dir`. Ignored for Flash.
|
|
device_serial: Optional device serial (e.g. "emulator-5554") to bind
|
|
execution to a specific device; distinct devices run concurrently.
|
|
If omitted, an available device is selected automatically. When
|
|
several devices are attached, confirm the target with the user first
|
|
(`adb devices -l` lists serials and authorization states).
|
|
verification_level: Optional, Pro only. Coarse Checker preset: `"off"`
|
|
(no audit; the Operator self-reports), `"final"` (one exit review
|
|
against the goal, the default), `"checkpoints"` (every plan
|
|
checkpoint is verified + exit review), `"strict"` (checkpoints with
|
|
a larger repair budget; a failed assert halts the run). Ignored for
|
|
Flash.
|
|
explorer_mode: Optional, Pro only. Explorer perception version used
|
|
by the Operator: `"flash"` (1-shot detection, the default),
|
|
`"pro"` (3-turn ReAct), `"ultra"` (deep pixel reasoning; slowest).
|
|
Ignored for Flash.
|
|
runtime_overrides: Optional TaskOptions JSON for this task only. Supports
|
|
execution_target ('android', 'computer', 'mixed') and plugins
|
|
([{id, profile}] references to saved authorized configurations; never secrets).
|
|
Omitted plugins selects matching enabled defaults; [] disables plugins.
|
|
Default Android tasks can use mixed extensions on demand without changing targets.
|
|
Computer tasks require an observation plugin, not an Android device.
|
|
Query mobile_workspace first; installation/authorization is in the plugin page.
|
|
timeout_seconds (total execution, default 1800; excludes queue/pause),
|
|
observation_timeout_seconds (60), observation_max_attempts (3),
|
|
device_command_timeout_seconds (10), action_failure_attempts (3),
|
|
worker_cleanup_timeout_seconds (30),
|
|
model (API model name), reasoning_effort, request timeout/retries,
|
|
recording and video analysis. Unknown fields and credentials are rejected.
|
|
"""
|
|
if runtime_overrides:
|
|
from apps.admin_console.services.task_import import TaskOptions
|
|
|
|
runtime_overrides = TaskOptions.model_validate(runtime_overrides).model_dump(
|
|
exclude_none=True
|
|
)
|
|
model = runtime_overrides.get("profile") or model
|
|
device_serial = runtime_overrides.get("device_serial") or device_serial
|
|
locked_app_package = runtime_overrides.get("locked_app_package") or locked_app_package
|
|
verification_level = runtime_overrides.get("verification_level") or verification_level
|
|
explorer_mode = runtime_overrides.get("explorer_mode") or explorer_mode
|
|
# 0. Validate and normalize model
|
|
if model.lower() not in ("flash", "pro"):
|
|
raise ValueError(f"Invalid model '{model}'. Must be either 'Flash' or 'Pro'.")
|
|
canonical_model = "Flash" if model.lower() == "flash" else "Pro"
|
|
# 0b. Validate the Pro tuning knobs before any trace exists so a typo is a
|
|
# plain tool error rather than a failed trace on disk.
|
|
verification_level, explorer_mode = _normalize_pro_tuning(verification_level, explorer_mode)
|
|
|
|
# 1. Generate a unique trace_id
|
|
trace_id = str(uuid.uuid4())
|
|
|
|
# 2. Initialize the trace store directory and status.json
|
|
trace_store.init_trace(
|
|
trace_id=trace_id,
|
|
task_desc=task_desc,
|
|
model=canonical_model,
|
|
conversation_id=conversation_id,
|
|
device_serial=device_serial,
|
|
)
|
|
|
|
# 2b. Strict device binding: an explicitly requested serial must be attached and
|
|
# authorized. Rejecting here prevents the task from silently running on a
|
|
# different device than the caller asked for. Runs after init_trace so the
|
|
# rejection carries a trace_id like every other response of this tool.
|
|
execution_target = (runtime_overrides or {}).get("execution_target", "android")
|
|
if device_serial and execution_target != "computer":
|
|
rejection = _validate_device_serial(device_serial)
|
|
if rejection:
|
|
trace_store.update_trace_status(trace_id, "failed", error=rejection["error"])
|
|
return {"trace_id": trace_id, **rejection}
|
|
|
|
# 3. Dispatch via unified Artemis Daemon scheduler if available (unless standalone forced)
|
|
if os.environ.get("ARTEMIS_STANDALONE") != "1":
|
|
try:
|
|
is_running, base_url = ensure_daemon_running(timeout=2.0, wait_ready=True)
|
|
if is_running:
|
|
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,
|
|
verification_level=verification_level,
|
|
explorer_mode=explorer_mode,
|
|
runtime_overrides=runtime_overrides,
|
|
base_url=base_url,
|
|
)
|
|
if resp and resp.get("status") in {"rejected", "error"}:
|
|
rejection_error = (
|
|
resp.get("error")
|
|
or resp.get("message")
|
|
or "Task rejected by Artemis Daemon."
|
|
)
|
|
trace_store.update_trace_status(trace_id, "failed", error=rejection_error)
|
|
return {
|
|
"trace_id": trace_id,
|
|
"status": "failed",
|
|
"error": rejection_error,
|
|
"message": f"Task rejected by Artemis Daemon: {rejection_error}",
|
|
}
|
|
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,
|
|
}
|
|
|
|
# Check if the task was already enqueued despite a slow or None
|
|
# response. The probe reads a shared lock file, so transient
|
|
# read failures get a few retries; if it never succeeds the
|
|
# verdict is "unknown" -- reporting "failed" here for a task
|
|
# that did land in the queue would invite a duplicate submit.
|
|
probe_failed = False
|
|
for probe_attempt in range(3):
|
|
try:
|
|
queued_tasks = DeviceExecutionLock.get_queued_tasks()
|
|
except Exception as exc:
|
|
if probe_attempt < 2:
|
|
time.sleep(0.5)
|
|
continue
|
|
probe_failed = True
|
|
logging.getLogger("mcp_server").warning(
|
|
f"Could not verify daemon queue for '{trace_id}': {exc}"
|
|
)
|
|
break
|
|
for q in queued_tasks:
|
|
if str(q.get("session_id")) == str(trace_id):
|
|
trace_store.update_trace_status(
|
|
trace_id, "running", device_serial=device_serial
|
|
)
|
|
return {
|
|
"trace_id": trace_id,
|
|
"status": "running",
|
|
"message": (
|
|
f"Autonomous task '{task_desc}' enqueued via unified Artemis Daemon.\n"
|
|
f"Trace ID: {trace_id}\n"
|
|
f"Model: {canonical_model}\n"
|
|
f"Live telemetry: streaming to web workspace."
|
|
),
|
|
"notes_dir": str(trace_store.get_trace_notes_dir(trace_id)),
|
|
"stdout_log": str(trace_store.get_trace_stdout_log_path(trace_id)),
|
|
"stderr_log": str(trace_store.get_trace_stderr_log_path(trace_id)),
|
|
}
|
|
break
|
|
|
|
if probe_failed:
|
|
# The task may or may not have been enqueued; do not mark
|
|
# the trace failed and do not let the caller resubmit
|
|
# blindly.
|
|
return {
|
|
"trace_id": trace_id,
|
|
"status": "unknown",
|
|
"message": (
|
|
f"Artemis Daemon gave no enqueue confirmation for task "
|
|
f"'{task_desc}' and the queue could not be inspected. The "
|
|
f"task may still have been enqueued. Poll "
|
|
f"mobile_manage_task(action='status', trace_id='{trace_id}') "
|
|
f"before resubmitting; if it still reports 'pending' after "
|
|
f"about a minute, treat it as not enqueued."
|
|
),
|
|
}
|
|
|
|
# When Daemon is running, refuse to launch a conflicting standalone runner on the same device
|
|
logging.getLogger("mcp_server").warning(
|
|
f"Daemon dispatch failed for task '{task_desc}'. Aborting dispatch to prevent runner collision."
|
|
)
|
|
return {
|
|
"trace_id": trace_id,
|
|
"status": "failed",
|
|
"error": f"Failed to enqueue task '{task_desc}' to running Artemis Daemon scheduler.",
|
|
"message": "Task rejected or timed out in Artemis Daemon. Standalone fallback blocked to prevent runner collision.",
|
|
}
|
|
except Exception as exc:
|
|
logging.getLogger("mcp_server").warning(f"Daemon dispatch failed: {exc}")
|
|
return {
|
|
"trace_id": trace_id,
|
|
"status": "failed",
|
|
"error": f"Daemon dispatch error: {exc}",
|
|
"message": "Failed to dispatch task to Artemis Daemon.",
|
|
}
|
|
|
|
# 4. Standalone Fallback: Resolve project root, python executable, and background runner module
|
|
from artemis.plugins.store import PluginStore
|
|
|
|
plugin_choices = (runtime_overrides or {}).get("plugins")
|
|
if (
|
|
execution_target != "android"
|
|
or plugin_choices
|
|
or (plugin_choices is None and PluginStore().selections("android"))
|
|
):
|
|
error = "插件及电脑任务需要统一任务服务,请启动 ARTEMIS 网页服务后重试;未启动独立任务。"
|
|
trace_store.update_trace_status(trace_id, "failed", error=error)
|
|
return {"trace_id": trace_id, "status": "failed", "error": error}
|
|
project_root = env_utils.get_project_root()
|
|
python_exe = env_utils.resolve_python_executable(project_root)
|
|
reserve_kwargs: dict[str, Any] = {
|
|
"description": f"MCP task: {task_desc[:120]}",
|
|
"session_id": trace_id,
|
|
"ingress": "mcp",
|
|
}
|
|
if device_serial:
|
|
reserve_kwargs["device_id"] = device_serial
|
|
queue_ticket = DeviceExecutionLock.reserve(**reserve_kwargs)
|
|
|
|
# 5. Spawn the background task runner as an independent subprocess
|
|
try:
|
|
cmd = [
|
|
python_exe,
|
|
"-m",
|
|
"mcp_server.background.task_runner",
|
|
"--trace-id",
|
|
trace_id,
|
|
"--task-desc",
|
|
task_desc,
|
|
"--model",
|
|
canonical_model,
|
|
"--conversation-id",
|
|
conversation_id or "",
|
|
]
|
|
if locked_app_package:
|
|
cmd.extend(["--locked-app-package", locked_app_package])
|
|
if app_path:
|
|
cmd.extend(["--app-path", app_path])
|
|
if expected_output_desc and canonical_model != "Flash":
|
|
cmd.extend(["--expected-output-desc", expected_output_desc])
|
|
if device_serial:
|
|
cmd.extend(["--device-serial", device_serial])
|
|
if verification_level:
|
|
cmd.extend(["--verification-level", verification_level])
|
|
if explorer_mode:
|
|
cmd.extend(["--explorer-pro-mode", explorer_mode])
|
|
|
|
env = os.environ.copy()
|
|
env["ARTEMIS_SESSION_ID"] = trace_id
|
|
env["ARTEMIS_TASK_INGRESS"] = "mcp"
|
|
env["ARTEMIS_TASK_WORKER"] = "1"
|
|
for option, variable in {
|
|
"model": "OPENAI_MODEL",
|
|
"reasoning_effort": "OPENAI_REASONING_EFFORT",
|
|
"request_timeout_seconds": "OPENAI_TIMEOUT_SECONDS",
|
|
"max_retries": "OPENAI_MAX_RETRIES",
|
|
"retry_interval_seconds": "OPENAI_RETRY_INTERVAL_SECONDS",
|
|
"record_video": "ARTEMIS_RECORDING_ENABLED",
|
|
"analyze_video": "ARTEMIS_VIDEO_ENABLED",
|
|
"observation_timeout_seconds": "ARTEMIS_OBSERVATION_TIMEOUT_SECONDS",
|
|
"observation_max_attempts": "ARTEMIS_OBSERVATION_MAX_ATTEMPTS",
|
|
"device_command_timeout_seconds": "ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS",
|
|
"action_failure_attempts": "ARTEMIS_ACTION_FAILURE_ATTEMPTS",
|
|
}.items():
|
|
if (runtime_overrides or {}).get(option) is not None:
|
|
env[variable] = (
|
|
str(runtime_overrides[option]).lower()
|
|
if isinstance(runtime_overrides[option], bool)
|
|
else str(runtime_overrides[option])
|
|
)
|
|
if device_serial:
|
|
env["ADB_DEVICE_SERIAL"] = device_serial
|
|
env["ARTEMIS_DEVICE_ID"] = device_serial
|
|
env[DeviceExecutionLock.QUEUE_TICKET_ENV] = queue_ticket
|
|
try:
|
|
ipc_port = read_ipc_port()
|
|
if ipc_port:
|
|
env["ARTEMIS_IPC_PORT"] = str(ipc_port)
|
|
except (OSError, ValueError):
|
|
# No readable IPC port file: the runner works without live telemetry.
|
|
pass
|
|
|
|
proc_kwargs = env_utils.get_detached_process_kwargs()
|
|
proc = subprocess.Popen(
|
|
cmd,
|
|
env=env,
|
|
cwd=project_root,
|
|
**proc_kwargs,
|
|
)
|
|
transfer_kwargs: dict[str, Any] = {
|
|
"description": f"MCP task: {task_desc[:120]}",
|
|
"session_id": trace_id,
|
|
"ingress": "mcp",
|
|
}
|
|
if device_serial:
|
|
transfer_kwargs["device_id"] = device_serial
|
|
|
|
DeviceExecutionLock.transfer_reservation(
|
|
queue_ticket,
|
|
proc.pid,
|
|
**transfer_kwargs,
|
|
)
|
|
|
|
# 5. Record the PID of the spawned subprocess
|
|
status_data = trace_store.read_status(trace_id)
|
|
if status_data:
|
|
status_data["pid"] = proc.pid
|
|
status_data["queue_ticket"] = queue_ticket
|
|
trace_store.write_status(trace_id, status_data)
|
|
|
|
# 6. Arm a watchdog: a runner that never opens its log files is hung in
|
|
# interpreter startup and must be killed instead of squatting on the
|
|
# queue as 'running' forever.
|
|
_start_spawn_watchdog(trace_id, proc.pid, queue_ticket, conversation_id)
|
|
threading.Thread(
|
|
target=_watch_execution,
|
|
args=(
|
|
proc,
|
|
trace_id,
|
|
task_timeout({"runtime_overrides": runtime_overrides}),
|
|
(runtime_overrides or {}).get("worker_cleanup_timeout_seconds"),
|
|
),
|
|
daemon=True,
|
|
name=f"artemis-task-budget-{trace_id[:8]}",
|
|
).start()
|
|
|
|
trace_dir = trace_store.get_trace_dir(trace_id)
|
|
stdout_log_path = os.path.join(trace_dir, "stdout.log")
|
|
stderr_log_path = os.path.join(trace_dir, "stderr.log")
|
|
|
|
response_dict: dict[str, Any] = {
|
|
"trace_id": trace_id,
|
|
"device_serial": device_serial or "auto-select",
|
|
"message": "Successfully started background task.",
|
|
"stdout_log": stdout_log_path,
|
|
"stderr_log": stderr_log_path,
|
|
}
|
|
if canonical_model != "Flash":
|
|
response_dict["notes_dir"] = os.path.join(trace_dir, "notes")
|
|
|
|
return response_dict
|
|
|
|
except Exception as e:
|
|
DeviceExecutionLock.cancel_reservation(queue_ticket)
|
|
trace_store.update_trace_status(
|
|
trace_id, "failed", error=f"Failed to spawn background runner: {e}"
|
|
)
|
|
return {
|
|
"trace_id": trace_id,
|
|
"status": "failed",
|
|
"message": f"Error starting background task: {e}",
|
|
}
|