feat: extract in-process MCP action layer, concurrent task dispatch, and spawn/reconcile hardening

- Extract device action execution into artemis/mcp action layer:
  typed ActionResult, capability manifest with startup validation,
  AdbActuator as the single physical implementation, in-memory
  ActionSession (replaces stdio subprocess MCP in validator), and
  McpActionExecutor preserving the legacy executor contract.
  adb_server keeps its external stdio/SSE surface unchanged and now
  reuses the actuator implementation; import-time side effects moved
  into configure_stdio_mode().
- Capability-gated prompt assembly (prompt_assembly.py): tool enums and
  teaching segments render per available backend tools; full-set output
  is byte-identical to the previous static text (locked by tests).
- Admin console task queue: serial worker replaced by a per-device
  concurrent dispatcher (ARTEMIS_CONCURRENCY_MODE=global restores
  serial semantics), per-session cancellation, strict device binding on
  enqueue, unified stdio piping with tee to trace stdout.log, and
  shutdown cleanup of all active runs. Fix _pid_exists exception order
  so PermissionError is treated as alive.
- mcp_server reliability: spawn watchdog kills runners stuck before
  interpreter startup and releases device reservations; task state
  reconcile uses psutil liveness, prefers runner-reported pids, and
  adds a 45s startup grace to stop false failure reports on Windows;
  conversation_id is now optional end-to-end; explicit device_serial is
  validated at submission.
- CLI install now writes ~/.claude/rules/artemis.md for Claude Code and
  migrates/removes the legacy CLAUDE.md injection block; ~/.claude.json
  is never rewritten when it fails to parse.
- Tests: Windows-portable stdio lifecycle and path assertions; new
  suites for action layer, prompt assembly, spawn watchdog, and task
  reconcile (895 passed, 3 skipped).
This commit is contained in:
weilinliu55 committed 2026-08-27 18:25:27 -07:00
1 parent 60312abbb0
commit cbdca1497c
52 files changed
+5735 -1649

No files matched your search

+5 -1
View File
@@ -366,7 +366,11 @@ if __name__ == "__main__":
parser.add_argument("--trace-id", required=True, help="Unique trace identifier")
parser.add_argument("--task-desc", required=True, help="Description of the task to run")
parser.add_argument("--model", required=True, help="Model to use ('Flash' or 'Pro')")
parser.add_argument("--conversation-id", required=True, help="Active conversation ID")
parser.add_argument(
"--conversation-id",
default="",
help="Conversation ID for wakeup notification routing (optional; empty disables routing)",
)
parser.add_argument("--locked-app-package", help="Package name of app to lock execution to")
parser.add_argument("--app-path", help="Path to local APK to install before running task")
parser.add_argument("--expected-output-desc", help="Expected output description")
+4 -2
View File
@@ -35,5 +35,7 @@ mcp = FastMCP(
),
)
# Import tools so they are registered with the mcp instance upon module load
import mcp_server.tools # noqa: E402, F401
# Tool registration is intentionally NOT done here: mcp_server/__init__.py
# imports mcp_server.tools (which imports this module), so importing this
# module from anywhere already registers every tool. Importing tools here as
# well would create a circular import (base -> tools -> base).
+27 -2
View File
@@ -24,24 +24,49 @@ if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
# Debug logging and stderr tee to capture crashes without breaking MCP stdio JSON-RPC
_LOG_MAX_BYTES = 5 * 1024 * 1024
def _rotate_log(path: str, max_bytes: int = _LOG_MAX_BYTES) -> None:
"""Rotates the log to <path>.old once it exceeds max_bytes, so long-lived
server processes cannot grow the debug logs without bound."""
try:
if os.path.exists(path) and os.path.getsize(path) > max_bytes:
old_path = path + ".old"
if os.path.exists(old_path):
os.remove(old_path)
os.replace(path, old_path)
except Exception:
pass
try:
_mcp_log_dir = os.path.join(PROJECT_ROOT, "scratch")
os.makedirs(_mcp_log_dir, exist_ok=True)
with open(os.path.join(_mcp_log_dir, "mcp_launch_debug.log"), "a", encoding="utf-8") as _f:
_launch_log = os.path.join(_mcp_log_dir, "mcp_launch_debug.log")
_rotate_log(_launch_log)
with open(_launch_log, "a", encoding="utf-8") as _f:
_f.write(
f"[{datetime.datetime.now()}] Artemis MCP Server launched! sys.argv: {sys.argv}, CWD: {os.getcwd()}\n"
)
class StderrTee:
def __init__(self, original, log_path):
def __init__(self, original, log_path, max_bytes=_LOG_MAX_BYTES):
self.original = original
self.log_path = log_path
self.max_bytes = max_bytes
_rotate_log(log_path, max_bytes)
self.log_file = open(log_path, "a", encoding="utf-8")
def write(self, data):
self.original.write(data)
self.log_file.write(data)
self.log_file.flush()
if self.log_file.tell() > self.max_bytes:
self.log_file.close()
_rotate_log(self.log_path, self.max_bytes)
self.log_file = open(self.log_path, "a", encoding="utf-8")
def flush(self):
self.original.flush()
+12 -23
View File
@@ -34,31 +34,20 @@ from artemis.utils.visualization import format_minimal_list_with_elements
async def mobile_get_device_state(view_type: str, device_serial: str | None = None) -> str:
"""Real-time mobile device state observer (for debugging and validation).
Use this tool to inspect the latest mobile status or to assist in tracking
the subagent's task progress.
Retrieves a real-time screenshot (returns local file URI) or a simplified
element tree text list of the target mobile device.
### Device Selection Modes:
1. **Direct Device Specification**: Provide `device_serial` to observe a specific
phone or emulator (e.g. `device_serial="63191FDKX00062"`).
2. **Automatic Device Selection**: Omit `device_serial` or leave it `None` to
automatically inspect the default/connected device.
**Diagnostics & User Preference**:
- When multiple devices are attached, prioritize letting the user specify or confirm the device.
- Run `adb devices` to check connected devices and identify their serial numbers.
Retrieves a real-time screenshot or a simplified UI element tree from the
target device — useful for inspecting device status or tracking a
subagent's progress.
Args:
view_type: The observation type. Supported values:
- "screenshot": Captures the real-time mobile screen and saves it to the workspace,
returning the local URI path of the image file (e.g., "file:///path/to/screenshot.jpg").
- "hierarchy": Directly returns the simplified text-labeled element list, which is
identical to what the mobile UI automation subagent sees when making decisions.
device_serial: Optional Android device serial number (e.g., "63191FDKX00062" or
"emulator-5554"). If specified, inspects state from that specific device. If omitted,
inspects the default connected device.
view_type: Observation type:
- "screenshot": captures the screen, saves it to the workspace, and
returns the image's local file URI.
- "hierarchy": returns the simplified text-labeled element list —
exactly what the automation subagent sees when making decisions.
device_serial: Optional device serial (e.g. "emulator-5554") to inspect
a specific device; omitted → the default connected device. With
several devices attached, confirm the target with the user
(`adb devices -l` lists serials).
"""
try:
controller = _get_controller(device_serial=device_serial)
+20 -44
View File
@@ -74,54 +74,30 @@ def _draw_action_overlay(bg_image_path: str, action_obj: dict, output_path: str)
@mcp.tool()
async def mobile_inspect_trace(action: str, trace_id: str, step_number: int | None = None) -> Any:
"""A specialized trace data retrieval tool designed to extract execution details of mobile automation tasks.
"""Retrieves execution details of a mobile automation task (running or finished).
Use this tool DURING a running task or AFTER it has finished (or failed) to
monitor progress, verify answers, diagnose agent errors, and debug.
It provides comprehensive runtime information (including the assigned
`device_serial`), raw agent outputs, and visual screenshots for both
**Flash** and **Pro** tasks.
Use it to monitor progress, verify answers, and diagnose agent errors for
both Flash and Pro tasks. Every result includes the assigned `device_serial`.
### Available Actions:
- **'view_summary'**: Retrieves a high-level execution summary across all
steps, including the target `device_serial`.
* **Pro Model**: Displays a hierarchical task plan summary, showing the
status, device serial, and descriptions of all completed, active, and pending steps.
* **Flash Model**: Displays an all-in-one execution chain containing the
complete reasoning (thoughts/motivation), device serial, and action taken (`click`,
`input_text`, etc.) for every single step.
- **'view_step_screenshots'**: Retrieves local file paths of screenshots for
a specific step (supports both Flash and Pro), along with `device_serial`. Returns:
1. `before_screenshot`: The original device screenshot observed by the
agent right before taking the action.
2. `after_screenshot`: A post-action screenshot capturing the failure or
mismatch state (if the action failed or was intercepted). Note: This is
conditional/optional and will be `None` (`null`) for normal/successful
steps where no extra post-action screenshot was captured.
3. `action_overlay_screenshot`: The `before_screenshot` with the agent's
action visually marked (e.g., red circle for taps, red arrow for
swipes). CRITICAL for verifying if the agent tapped the correct UI
element.
4. `device_serial`: The serial of the device on which this step was executed.
- **'view_step_details'**: Retrieves deep runtime logs, raw VLM thoughts,
and exact actions for a specific step, along with `device_serial`.
* **Pro Model**: Fully supported. Returns detailed per-step diagnostic
data.
* **Flash Model**: DO NOT USE. (Flash reasoning and actions are already
fully included in 'view_summary', making this action redundant).
### Actions
- **'view_summary'**: High-level execution summary across all steps.
Pro: hierarchical task plan with per-step status; Flash: full execution
chain with each step's reasoning and action.
- **'view_step_screenshots'**: Local file paths of one step's screenshots:
`before_screenshot` (what the agent saw), `after_screenshot` (only set
when the action failed/was intercepted, else null), and
`action_overlay_screenshot` (the action visually marked — e.g. red circle
for taps — key for verifying the agent tapped the right element).
- **'view_step_details'**: Deep per-step diagnostics (raw VLM thoughts,
exact actions). Pro only — DO NOT use for Flash tasks ('view_summary'
already carries their full execution chain).
Args:
action: STR. **REQUIRED**. The retrieval action to perform
(`"view_summary"`, `"view_step_screenshots"`, or
`"view_step_details"`).
trace_id: STR. **REQUIRED**. The unique session identifier of the task
(returned by `mobile_run_task`).
step_number: INT. **CONDITIONAL**. The 1-indexed step number to query. -
You MUST provide this if `action="view_step_screenshots"` or
`action="view_step_details"`. - You MUST omit or leave this empty if
`action="view_summary"`.
action: `"view_summary"`, `"view_step_screenshots"`, or
`"view_step_details"`.
trace_id: The task's session identifier from `mobile_run_task`.
step_number: 1-indexed step to query; required for the two per-step
actions, omit for `view_summary`.
"""
project_root = env_utils.get_project_root()
db_path = os.path.join(trace_store.TRACES_DIR, "data_engine.db")
+178 -100
View File
@@ -27,6 +27,161 @@ from mcp_server.notifiers import notify
from mcp_server.utils import env_utils, trace_store
from artemis.runtime import DeviceExecutionLock, process_supervisor
_LIVENESS_FAILURE_ERROR = "Task runner process terminated unexpectedly."
# Grace window covering the spawn race: the launcher pid may already have exited
# while the real runner has not yet registered its own pid in the DataEngine DB.
_STARTUP_GRACE_SECONDS = 45.0
def _find_data_engine_db() -> str | None:
db_path = os.path.join(trace_store.TRACES_DIR, "data_engine.db")
if os.path.exists(db_path):
return db_path
db_path = os.path.join(env_utils.get_project_root(), "traces", "data_engine.db")
return db_path if os.path.exists(db_path) else None
def _pid_alive(pid: int | None) -> bool:
if not pid:
return False
try:
import psutil
proc = psutil.Process(int(pid))
return proc.is_running() and proc.status() != psutil.STATUS_ZOMBIE
except Exception:
try:
os.kill(int(pid), 0)
return True
except OSError:
return False
def _session_tracked_by_lock(trace_id: str) -> bool:
"""True when the device-lock layer still tracks this session (queued or active owner)."""
try:
for q_item in DeviceExecutionLock.get_queued_tasks():
if str(q_item.get("session_id")) == trace_id:
return True
except Exception:
pass
try:
for owner in DeviceExecutionLock.get_active_owners().values():
if owner.session_id and str(owner.session_id) == trace_id:
return True
except Exception:
pass
return False
def _reconcile_task_state(
trace_id: str, status_data: dict[str, Any]
) -> tuple[str, int | None, bool]:
"""Reconcile status.json with the DataEngine DB and real process liveness.
The DB session row is written by the runner process itself (``os.getpid()``),
so its pid and terminal status outrank the pid recorded at spawn time -- on
Windows that spawn pid is a short-lived venv launcher, not the runner. A dead
or missing pid alone never fails a task that the DB, the device-lock queue,
or an active lock owner still reports as alive.
Returns ``(current_status, pid, is_alive)`` and persists any correction back
to status.json (including recovering from a previously misreported failure).
"""
current_status = status_data.get("status", "unknown")
pid = status_data.get("pid")
dirty = False
db_status: str | None = None
db_pid: int | None = None
db_path = _find_data_engine_db()
if db_path:
try:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
row = conn.execute(
"SELECT status, pid FROM sessions WHERE session_id = ? ORDER BY start_time DESC LIMIT 1",
(trace_id,),
).fetchone()
conn.close()
if row:
db_status = row["status"]
db_pid = row["pid"]
except Exception:
pass
if db_pid and db_pid != pid:
pid = db_pid
status_data["pid"] = pid
dirty = True
# The DB terminal verdict only fills in a status.json that has not reached a
# terminal state itself, or corrects a liveness-inferred failure. It never
# overrides a completed result or an explicit user cancellation.
liveness_failed = (
current_status == "failed" and status_data.get("error") == _LIVENESS_FAILURE_ERROR
)
if db_status in ("completed", "success", "failed", "cancelled") and (
current_status in ("running", "pending") or liveness_failed
):
canonical = "completed" if db_status in ("completed", "success") else db_status
if current_status != canonical:
current_status = canonical
status_data["status"] = canonical
if canonical != "failed" and status_data.get("error") == _LIVENESS_FAILURE_ERROR:
status_data["error"] = None
if not status_data.get("end_time"):
status_data["end_time"] = time.time()
dirty = True
is_alive = False
elif current_status in ("running", "pending"):
if not pid:
# No pid recorded (daemon dispatch never writes one): liveness cannot be
# inferred, so the task is assumed alive -- matching the historical
# behavior of only checking liveness when a pid exists.
is_alive = True
else:
is_alive = _pid_alive(pid)
if not is_alive:
if _session_tracked_by_lock(trace_id):
# Still queued for a device or owned by a live lock holder.
is_alive = True
elif db_status in ("running", "pending") and db_pid:
# The runner registered itself and its process is gone: truly dead.
current_status = "failed"
_mark_liveness_failure(trace_id, status_data)
elif (time.time() - (status_data.get("start_time") or 0)) < _STARTUP_GRACE_SECONDS:
is_alive = True
else:
current_status = "failed"
_mark_liveness_failure(trace_id, status_data)
else:
is_alive = False
if dirty:
trace_store.write_status(trace_id, status_data)
return current_status, pid, is_alive
def _mark_liveness_failure(trace_id: str, status_data: dict[str, Any]) -> None:
trace_store.update_trace_status(trace_id, "failed", error=_LIVENESS_FAILURE_ERROR)
status_data["status"] = "failed"
status_data["error"] = _LIVENESS_FAILURE_ERROR
conv_id = status_data.get("conversation_id")
if conv_id:
try:
notify(
conversation_id=conv_id,
message=(
f"Artemis background task died unexpectedly for trace '{trace_id}'. "
f"Error: {_LIVENESS_FAILURE_ERROR}"
),
event_type="failed",
payload={"trace_id": trace_id, "error": _LIVENESS_FAILURE_ERROR},
)
except Exception:
pass
@mcp.tool()
def mobile_manage_task(
@@ -34,53 +189,31 @@ def mobile_manage_task(
) -> dict[str, Any]:
"""Manages the lifecycle and retrieves the status of a background mobile automation task.
This is your primary diagnostic and control tool. You MUST use this tool to
poll the task status
when your 1-minute fallback timer triggers (as requested by
`mobile_run_task`). You can also use
it to gracefully abort a task or forcefully correct the subagent's behavior
mid-flight.
This is your primary diagnostic and control tool for tasks started by
`mobile_run_task`. You MUST use it to poll the task status when your
1-minute fallback timer triggers (as required by `mobile_run_task`). You
can also steer a subagent that is stuck or off-track, or abort a task.
### Available Actions:
- **'status'**: Retrieves execution progress, elapsed time, and metadata.
Returns a JSON object containing:
* `trace_id`: Unique identifier for the task.
* `status`: Task lifecycle state ('running', 'completed', 'failed', 'cancelled').
* `device_serial`: The Android device serial number assigned to/running this task
(essential in multi-device environments to know which phone this task belongs to).
* `task_desc`: The goal/description of the task.
* `model`: 'Flash' or 'Pro'.
* `elapsed_seconds`: Execution duration in seconds.
* `progress`: Live progress data (current turn, latest thoughts and actions for Flash;
active task plan for Pro).
* **How to use the output**:
- For **Pro Model**, check `progress.task_plan` to see if the
agent's long-term plan aligns with your goal.
- For **Flash Model**, monitor `current_turn`, `latest_action`, and
`latest_thought`. If the turn count increases rapidly without
progressing, or the thought indicates it is stuck, you should
intervene.
- **'inject_instruction'**: Injects real-time guidance when the subagent
makes a mistake, gets stuck, or loops.
* **Pro Model**: Applied at the start of the next turn's planning phase.
* **Flash Model**: Applied at the start of the next reactive execution
loop.
* *Note*: You MUST provide the `instruction` argument for this action.
- **'stop'**: Forcefully terminates the subagent (Pro or Flash), immediately
halting device interactions and releasing the device. Use this if the task
is complete, irreparably broken, or running out of control.
### Actions
- **'status'**: Returns `trace_id`, `status` ('running'/'completed'/'failed'/
'cancelled'), `device_serial` (which phone owns this task in multi-device
setups), `task_desc`, `model`, `elapsed_seconds`, and `progress` (Flash:
current turn, latest thought/action — intervene if turns climb without
progress or the thought indicates it is stuck; Pro: the active task plan
— check it still aligns with your goal).
- **'inject_instruction'**: Injects real-time guidance mid-flight when the
subagent errs, stalls, or loops. Applied at the start of the next
planning turn (Pro) or the next reactive loop (Flash). Requires
`instruction`.
- **'stop'**: Forcefully terminates the subagent, immediately halting
device interactions and releasing the device. Use when the task is done,
irreparably broken, or running out of control.
Args:
action: STR. **REQUIRED**. The management action to perform. Must be
exactly `"status"`, `"stop"`, or `"inject_instruction"`.
trace_id: STR. **REQUIRED**. The unique session identifier of the task
(returned by `mobile_run_task`).
instruction: STR. **CONDITIONAL**. The real-time correction or guidance
string. - You MUST provide this if `action="inject_instruction"`. -
You MUST leave this empty or omit it if `action="status"` or
`action="stop"`.
action: `"status"`, `"inject_instruction"`, or `"stop"`.
trace_id: The task's session identifier from `mobile_run_task`.
instruction: Guidance string; required for `inject_instruction`, omit
otherwise.
"""
status_data = trace_store.read_status(trace_id)
if not status_data:
@@ -90,62 +223,7 @@ def mobile_manage_task(
"message": f"Trace ID '{trace_id}' not found.",
}
current_status = status_data.get("status", "unknown")
pid = status_data.get("pid")
is_alive = False
project_root = env_utils.get_project_root()
db_path = os.path.join(trace_store.TRACES_DIR, "data_engine.db")
if not os.path.exists(db_path):
db_path = os.path.join(project_root, "traces", "data_engine.db")
# If status is still running or pending, sync from database if available
if current_status in ("running", "pending") and os.path.exists(db_path):
try:
conn_st = sqlite3.connect(db_path)
conn_st.row_factory = sqlite3.Row
cur_st = conn_st.cursor()
cur_st.execute(
"SELECT status, pid FROM sessions WHERE session_id = ? ORDER BY start_time DESC LIMIT 1",
(trace_id,),
)
row_st = cur_st.fetchone()
if row_st:
db_status = row_st["status"]
db_pid = row_st["pid"]
if db_pid and not pid:
pid = db_pid
status_data["pid"] = pid
trace_store.write_status(trace_id, status_data)
if db_status in ("completed", "success", "failed", "cancelled"):
canonical_status = "completed" if db_status in ("completed", "success") else db_status
current_status = canonical_status
status_data["status"] = canonical_status
trace_store.write_status(trace_id, status_data)
conn_st.close()
except Exception:
pass
if pid and current_status in ("running", "pending"):
try:
os.kill(pid, 0)
is_alive = True
except OSError:
current_status = "failed"
error_text = "Task runner process terminated unexpectedly."
trace_store.update_trace_status(trace_id, "failed", error=error_text)
conv_id = status_data.get("conversation_id")
if conv_id:
try:
notify(
conversation_id=conv_id,
message=f"Artemis background task died unexpectedly for trace '{trace_id}'. Error: {error_text}",
event_type="failed",
payload={"trace_id": trace_id, "error": error_text},
)
except Exception:
pass
status_data = trace_store.read_status(trace_id) or status_data
current_status, pid, is_alive = _reconcile_task_state(trace_id, status_data)
trace_dir = trace_store.get_trace_dir(trace_id)
+218 -106
View File
@@ -16,134 +16,222 @@
import os
import subprocess
import sys
import threading
import time
from typing import Any
import uuid
from mcp_server.base import mcp
from mcp_server.notifiers import notify
from mcp_server.utils import env_utils, trace_store
from artemis.runtime import DeviceExecutionLock
# 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 _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:
pass
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:
from artemis.runtime import device_pool
from artemis.runtime.device_lock import DeviceExecutionLock
norm = DeviceExecutionLock._normalize_device_id
attached = {norm(d.serial): d.state for d in device_pool.list_devices()}
except Exception:
return None
if not attached:
# Enumeration unavailable (no adb / transient failure): let the task
# proceed and fail downstream with a clear no-device error instead.
return None
dev_state = attached.get(norm(device_serial))
if dev_state == "device":
return None
if dev_state is None:
detail = (
f"Device '{device_serial}' is not connected. "
f"Attached devices: {sorted(attached) if attached else 'none'}."
)
else:
detail = f"Device '{device_serial}' is attached but not ready (state: '{dev_state}')."
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)."
),
}
@mcp.tool()
def mobile_run_task(
task_desc: str,
conversation_id: 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,
) -> dict[str, Any]:
"""Starts a specialized mobile UI automation subagent to autonomously plan and execute tasks on a connected Android device.
"""Starts an autonomous mobile UI automation subagent on a connected Android device.
With this tool, you can delegate complex mobile workflows and UI
interactions to a background agent.
This tool supports two execution models: **Flash** (for rapid,
straightforward UI scripts) and **Pro** (for complex, exploratory tasks
requiring deep reasoning).
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.
### Device Selection & Multi-Device Support (CRITICAL)
ARTEMIS supports multi-device execution and provides two device selection modes:
1. **Direct Device Specification**: Specify `device_serial` explicitly
(e.g., `device_serial="63191FDKX00062"` or `device_serial="emulator-5554"`).
Tasks targeting different devices run concurrently via per-device locking.
2. **Automatic Device Selection**: If `device_serial` is omitted or `None`,
ARTEMIS will automatically select the connected device or allocate an idle device
from the device pool.
### Model selection (routing)
- **Flash** (default, preferred): simple, deterministic tasks completable
within ~30 UI steps. No log/video analysis, no ADB shell, no persistent
plan or notes — unsuitable for exploration, complex error recovery,
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, video analysis,
multi-step planning, or detailed written output (via notes and
`expected_output_desc`).
**Device Selection Policy & Diagnostics**:
- **Prioritize User Choice**: When multiple devices or emulators are connected (or when
user intent is not explicitly specified), **ALWAYS PRIORITIZE ASKING THE USER** to
choose or confirm the target device before launching tasks.
- **Device Diagnosis with `adb devices`**: Always use `adb devices` (or `adb devices -l`)
to inspect connected hardware, verify authorization states (`device` vs `unauthorized`),
and retrieve active device serials when diagnosing or before delegating tasks.
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.
### Model Selection Guide (CRITICAL FOR ROUTING)
You MUST choose the appropriate `model` based on the task complexity:
- **Flash**: Best for simple, deterministic tasks that can be completed
within **30 UI steps**. You should prioritize using this model to complete
the task.
*Limitations*: Flash CANNOT analyze video recordings, query system logs,
execute arbitrary ADB shell commands, maintain a structured task plan, or
write custom execution notes/reports. Do not use Flash if the task
requires exploration, complex error recovery, continuous monitoring/polling
(due to step count limits and lack of state persistence), or outputting
large amounts of detailed information (as Flash cannot access the
notes/scratchpad system to retain extensive details across steps; use
**Pro** instead).
- **Pro**: Required for complex, non-linear, or exploratory tasks (e.g.,
browsing deep lists, troubleshooting app crashes, tasks requiring multi-step
planning), continuous monitoring or polling tasks (e.g., periodically checking
UI state, polling until specific conditions or events occur, watching progress,
or waiting for background changes), OR when you need the subagent to extract,
remember, and output large amounts of detailed information. Pro is slower but
fully featured.
*Capabilities*: Supports full video analysis, system log querying, ADB
shell access, robust monitoring/polling execution loops with multi-agent
planning, and generating custom text outputs/reports via the expected
output agent and structured notes (`notes_dir`).
Regardless of the model, precise timing for waiting actions cannot be
guaranteed due to latency. If you need to implement a wait, please note that
the total waiting time comprises both the time required for the Artemis large
model to generate output and the actual waiting duration. (For Flash, the
interval between output steps is typically 3-7 seconds, whereas Pro requires
25-35 seconds.)
For long-term repetitive tasks, run the workflow once with this tool to
discover the path, then author a dedicated automation script instead of
repeatedly delegating.
This tool is non-blocking and will immediately return a dictionary
containing:
- `trace_id`: The unique session identifier of the task for status tracking
and inspection.
- `device_serial`: The assigned target device serial (or "auto-select").
- `notes_dir`: (Pro Model Only) The directory where the subagent's execution
files/notes are located.
- `stdout_log`: The path to the execution framework log.
- `stderr_log`: The path to the error log containing critical failures and
Python tracebacks (essential for debugging).
Upon task completion (success or failure), the system will trigger a
Reactive Wakeup to notify you.
CRITICAL BEHAVIORAL RULES:
1. **Fallback Timer Constraint**: You **MUST** set a background timer to
check the task status at least once every 1 minute as a safety fallback.
2. **Post-Task Inspection**: After completion, retrieve and inspect the
execution logs (`stderr_log`/`stdout_log`) to verify success.
3. **Inspecting Notes (Pro Model Only)**: If running with Pro, you can
inspect the notes/plans recorded in `notes_dir` to extract critical
execution results passed back by the subagent.
### 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: A clear, highly detailed, actionable task description for the
mobile subagent. Since execution is autonomous and one-shot, provide
all necessary context, exact UI actions to take, and clear termination
conditions. If `model="Pro"`, you can explicitly instruct the subagent
on what specific information it must record in the notes.
conversation_id: Your current active Conversation ID. Critical for
routing the wakeup notification.
model: Must be either `"Flash"` or `"Pro"`. - Choose `"Flash"` for fast,
simple UI tasks under 30 steps without log/video analysis or
monitoring/polling needs. - Choose `"Pro"` for complex tasks requiring
planning, ADB shell commands, logs, video exploration, or continuous
monitoring and polling tasks.
locked_app_package: Optional package name of the app to lock execution
to. The agent will auto-launch and restrict actions to this app.
app_path: Optional path to a local APK to install on the device before
running the task.
expected_output_desc: Optional (Pro Model Only). If provided, a
summarization agent will aggregate the interaction history
(motivation, actions, videos) into a summary report saved as
"output.md" in `notes_dir`. Ignored if `model="Flash"`.
device_serial: Optional Android device serial number (e.g. "63191FDKX00062"
or "emulator-5554"). If specified, binds execution strictly to that device,
allowing multi-device concurrent automation. If omitted, ARTEMIS will
automatically select an available device. Prioritize letting the user select
the target device if multiple devices are attached.
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).
"""
# 0. Validate and normalize model
if model.lower() not in ("flash", "pro"):
@@ -162,6 +250,16 @@ def mobile_run_task(
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.
if device_serial:
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:
@@ -181,6 +279,15 @@ def mobile_run_task(
conversation_id=conversation_id,
base_url=base_url,
)
if resp and resp.get("status") == "rejected":
rejection_error = resp.get("error") 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(
@@ -275,7 +382,7 @@ def mobile_run_task(
"--model",
canonical_model,
"--conversation-id",
conversation_id,
conversation_id or "",
]
if locked_app_package:
cmd.extend(["--locked-app-package", locked_app_package])
@@ -330,6 +437,11 @@ def mobile_run_task(
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)
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")
+1 -1
View File
@@ -57,7 +57,7 @@ def init_trace(
trace_id: str,
task_desc: str,
model: str,
conversation_id: str,
conversation_id: str | None = None,
device_serial: str | None = None,
) -> dict[str, Any]:
"""Initializes the trace directory and creates the initial status.json file."""