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:
1 parent
60312abbb0
commit
cbdca1497c
52 files changed
+5735
-1649
No files matched your search
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in new issue
Block a user