# 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_manage_task.""" import json import logging import os import signal import sqlite3 import sys import time from typing import Any from mcp_server.base import mcp from mcp_server.notifiers import notify from mcp_server.utils import env_utils from artemis.runtime import ( DeviceExecutionLock, is_daemon_running, process_supervisor, stop_task_on_daemon, trace_store, ) from artemis.runtime.process_probe import pid_is_alive from artemis.runtime.task_progress import progress_snapshot from artemis.runtime.task_control import ( add_instruction, control_dir, list_instructions, read_state, request_pause, request_resume, ) logger = logging.getLogger(__name__) _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 _web_session_status(trace_id: str) -> dict[str, Any] | None: """Web submissions have a session row but no MCP launcher status.json.""" db_path = _find_data_engine_db() if not db_path: return None try: with sqlite3.connect(db_path) as conn: conn.row_factory = sqlite3.Row row = conn.execute( "SELECT status, pid, start_time, end_time, device_info " "FROM sessions WHERE session_id = ? ORDER BY start_time DESC LIMIT 1", (trace_id,), ).fetchone() except sqlite3.Error as exc: logger.warning("Could not read web session %s: %s", trace_id, exc) return None if not row: return None data = dict(row) if data.get("device_info"): try: device = json.loads(data["device_info"]) data["device_serial"] = device.get("device_id") if isinstance(device, dict) else None except (TypeError, ValueError): pass return data def _pid_alive(pid: int | None) -> bool: if not pid: return False try: pid_int = int(pid) except (TypeError, ValueError): return False return pid_is_alive(pid_int) def _session_tracked_by_lock(trace_id: str) -> bool | None: """Whether the device-lock layer still tracks this session (queued or active owner). Returns ``True`` when tracked, ``False`` when both probes succeeded and the session is definitively absent, and ``None`` when a probe failed — an unreadable queue or lock dir may hide a live session, so the caller must not treat a failed probe as evidence of death (same "uncertain defaults to alive" stance as ``pid_is_alive``). """ uncertain = False try: for q_item in DeviceExecutionLock.get_queued_tasks(): if str(q_item.get("session_id")) == trace_id: return True except Exception as exc: uncertain = True print(f"Could not read device queue for {trace_id}: {exc}", file=sys.stderr) try: for owner in DeviceExecutionLock.get_active_owners().values(): if owner.session_id and str(owner.session_id) == trace_id: return True except Exception as exc: uncertain = True print(f"Could not read device owners for {trace_id}: {exc}", file=sys.stderr) return None if uncertain else 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 # "success" is a legacy alias for the canonical "completed" terminal # status; consumers of this reconcile must only ever see "completed". if current_status == "success": current_status = "completed" status_data["status"] = "completed" dirty = True 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 sqlite3.Error as exc: # Reconciliation then relies on status.json and lock evidence only. print(f"Could not read DB session row for {trace_id}: {exc}", file=sys.stderr) 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", "interrupted") and ( current_status in ("running", "pending", "task_paused", "resuming") 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", "task_paused", "resuming"): 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: tracked = _session_tracked_by_lock(trace_id) if tracked or tracked is None: # Still queued for a device or owned by a live lock holder -- # or the lock probe failed, in which case the session may be # hidden and must not be declared dead on this poll. 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 as exc: # Notification dispatch is best-effort; the failure verdict is # already persisted in status.json. logger.debug( "Liveness-failure notification skipped for trace %s: %s", trace_id, exc, exc_info=True, ) @mcp.tool() def mobile_manage_task( action: str, trace_id: str = "", instruction: str | None = None, release_loop: bool = False, request_id: str | None = None, review_status: str | None = None, review_reason: str = "", user_confirmed: bool = False, ) -> dict[str, Any]: """Manages the lifecycle and retrieves the status of a background mobile automation task. This is your primary diagnostic and control tool for tasks started by `mobile_run_task` or the web workspace. Poll with reasonable intervals while following a running task; use request diagnostics for unexplained waits. You can also steer a runner that is stuck or off-track, or stop a task. ### Actions - lookup: Resolve a submission by request_id without starting or resubmitting it. - deletion_preview / delete: Show affected session IDs, then delete only when the user requested it (user_confirmed=true). Active tasks cannot be deleted. An exploration deletes its conversation runs, preserving independent reruns. - review: Record ONLY the user's explicitly stated passed/failed decision with review_reason and user_confirmed=true. Never label the agent's verdict as human. - **'status'**: Returns `trace_id`, `status` ('running'/'pausing'/'task_paused'/ 'resuming'/'completed'/'failed'/'cancelled'/'interrupted'), `device_serial` (which phone owns this task in multi-device setups), `task_desc`, `model`, `elapsed_seconds`, a `test_summary` (when the run declared verification check items: machine-readable passed/failed/inconclusive/unchecked counts plus failed-item details — no need to parse report prose), `test_result` (Flash and Pro: status passed/failed/inconclusive/blocked, reason and evidence; completed only means execution ended), 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). - **'pause'**: Requests a pause, returning `pausing` until the runner releases the device. Pro can interrupt a pending model response or read-only diagnostic; a device action already in progress must finish safely first. Check status for `task_paused` before assuming the device is free. - **'resume'**: Queues a paused task for its device again; optional `instruction` provides guidance before the next action. - **'inject_instruction'**: Injects real-time guidance mid-flight when the subagent errs, stalls, or loops. An in-flight model response or read-only helper is cancelled immediately, then the runner observes the device again and consumes the new guidance. An already-started device action finishes safely before the runner changes direction. Requires `instruction` (unless `release_loop=True`). To end a continuous monitoring task ([Loop:continuous] milestone) gracefully, you MUST pass `release_loop=True` — this is the only signal that unlocks the milestone's completion; natural-language "please stop" phrasing in `instruction` is NOT interpreted as a stop signal. - **'guidance_history'**: Lists submitted instructions with `pending` or `consumed` status; a submitted instruction is not proof it was applied. - **'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. Prefer `inject_instruction` with `release_loop=True` when a monitoring task should wind down cleanly instead of being killed. Args: action: `"status"`, `"pause"`, `"resume"`, `"inject_instruction"`, `"guidance_history"`, or `"stop"`. trace_id: The task's session identifier from `mobile_run_task` or the web UI. instruction: Guidance string; required for `inject_instruction` (optional when `release_loop=True`), omit otherwise. release_loop: With `inject_instruction`: explicit user stop signal that authorizes the subagent to complete continuous monitoring loops and finish the task gracefully. """ from mcp_server.tools.automation import _id, _request import asyncio if action == "lookup": from mcp_server.task_requests import lookup return lookup(_id(request_id)) _id(trace_id) if action in {"deletion_preview", "delete", "review"}: if action != "deletion_preview" and not user_confirmed: raise ValueError("此动作需要用户明确要求;AI 自行判断不能记作人工复验") if action == "deletion_preview": return asyncio.run(_request("GET", f"/api/sessions/{trace_id}/deletion-preview")) if action == "delete": return asyncio.run(_request("POST", f"/api/sessions/{trace_id}/delete")) if review_status not in {"passed", "failed"}: raise ValueError("review_status 必须为 passed 或 failed") result = asyncio.run( _request( "PUT", f"/api/automation/reports/{trace_id}/review", json={"status": review_status, "reason": review_reason}, ) ) return {"trace_id": trace_id, "test_result": result.get("test_result")} status_data = trace_store.read_status(trace_id) or _web_session_status(trace_id) if not status_data: return { "trace_id": trace_id, "status": "unknown", "message": f"Trace ID '{trace_id}' not found.", } current_status, pid, is_alive = _reconcile_task_state(trace_id, status_data) control_status = read_state(trace_id).get("status") if is_alive else None if control_status in ("task_paused", "resuming") and current_status in ("running", "pending"): current_status = control_status elif ( is_alive and current_status in ("running", "pending") and (control_dir(trace_id) / "pause.request").exists() ): current_status = "pausing" trace_dir = trace_store.get_trace_dir(trace_id) if action == "status": start_time = status_data.get("start_time") end_time = status_data.get("end_time") or time.time() elapsed = round(end_time - start_time, 1) if start_time else 0 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") device_serial = status_data.get("device_serial") if not device_serial and os.path.exists(db_path): try: conn_dev = sqlite3.connect(db_path) conn_dev.row_factory = sqlite3.Row cur_dev = conn_dev.cursor() cur_dev.execute( "SELECT device_info FROM sessions WHERE session_id = ? OR pid = ? ORDER BY start_time DESC LIMIT 1", (trace_id, pid), ) row_dev = cur_dev.fetchone() if row_dev and row_dev["device_info"]: try: d_info = json.loads(row_dev["device_info"]) if isinstance(d_info, dict) and d_info.get("device_id"): device_serial = d_info["device_id"] status_data["device_serial"] = device_serial trace_store.write_status(trace_id, status_data) except (ValueError, TypeError, OSError): # Malformed device_info or failed cache write: the # serial simply stays unknown for this poll. pass conn_dev.close() except sqlite3.Error: # DB probe for the serial is optional enrichment. pass if not device_serial and pid: try: owners = DeviceExecutionLock.get_active_owners() for clean_id, owner in owners.items(): if owner.pid == pid or owner.session_id == trace_id: device_serial = owner.device_id status_data["device_serial"] = device_serial trace_store.write_status(trace_id, status_data) break except OSError: # Lock-owner probe / cache write failed: serial stays unknown. pass response: dict[str, Any] = { "trace_id": trace_id, "status": current_status, "device_serial": device_serial, "elapsed_seconds": elapsed, "task_desc": status_data.get("task_desc"), "model": status_data.get("model"), "stdout_log": os.path.join(trace_dir, "stdout.log"), "stderr_log": os.path.join(trace_dir, "stderr.log"), } lifecycle_db = _find_data_engine_db() if lifecycle_db: from apps.admin_console.services.task_lifecycle import task_lifecycle try: response["lifecycle"] = task_lifecycle(trace_id, db_path=lifecycle_db) except (sqlite3.Error, OSError, ValueError) as exc: logger.warning("Could not read lifecycle for %s: %s", trace_id, exc) if "lifecycle" not in response: response["lifecycle"] = progress_snapshot( session={**status_data, "status": current_status}, control=read_state(trace_id) ) # Which UI-hierarchy source served the run ("helper" or "uiautomator"), # plus any mid-run switch; written by the agent once the device connects. if status_data.get("hierarchy_backend"): response["hierarchy_backend"] = status_data.get("hierarchy_backend") if status_data.get("hierarchy_backend_note"): response["hierarchy_backend_note"] = status_data.get("hierarchy_backend_note") if status_data.get("model", "").lower() != "flash": response["notes_dir"] = os.path.join(trace_dir, "notes") if current_status == "failed": response["error"] = status_data.get("error") elif current_status == "completed": response["result"] = status_data.get("result") # Machine-readable test summary (written by exit settlement): callers # get assertion results without parsing report prose. for candidate in ( os.path.join(trace_store.TRACES_DIR, trace_id, "run_outcome.json"), os.path.join(trace_dir, "run_outcome.json"), ): if os.path.exists(candidate): try: with open(candidate, encoding="utf-8") as f: run_outcome = json.load(f) tests = run_outcome.get("tests") or {} response["test_summary"] = { "task_status": run_outcome.get("task_status"), **tests, } from artemis.runtime.case_result import resolve_case_result test_result = resolve_case_result( run_outcome, current_status, response.get("error") or "" ) response["test_result"] = test_result.model_dump() if test_result else None except (OSError, ValueError, TypeError): # Unreadable or malformed run_outcome.json: omit the summary. pass break if "test_result" not in response: from artemis.runtime.case_result import resolve_case_result test_result = resolve_case_result(None, current_status, response.get("error") or "") response["test_result"] = test_result.model_dump() if test_result else None from apps.admin_console.services.case_review import reviewed_result from apps.admin_console.services.automation_store import AutomationStore from pathlib import Path review_store = AutomationStore(Path(db_path)) response["test_result"] = reviewed_result( trace_id, response.get("test_result"), review_store ) if current_status in ("running", "pending", "task_paused", "resuming") and is_alive: progress: dict[str, Any] = {} 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") session_id = None if os.path.exists(db_path): try: conn = sqlite3.connect(db_path) conn.row_factory = sqlite3.Row cursor = conn.cursor() cursor.execute( "SELECT session_id FROM sessions WHERE pid = ? ORDER BY start_time DESC LIMIT 1", (pid,), ) session_row = cursor.fetchone() if session_row: session_id = session_row["session_id"] conn.close() except Exception as db_err: print(f"Error querying data_engine.db for session: {db_err}", file=sys.stderr) if session_id: if status_data.get("model", "").lower() == "flash": progress["phase"] = "running_flash_loop" if os.path.exists(db_path): try: conn2 = sqlite3.connect(db_path) conn2.row_factory = sqlite3.Row cur2 = conn2.cursor() cur2.execute( "SELECT coalesce(max(step_number),0) as cnt FROM steps WHERE session_id = ?", (session_id,), ) row_cnt = cur2.fetchone() if row_cnt: progress["current_turn"] = row_cnt["cnt"] progress["current_step"] = row_cnt["cnt"] cur2.execute( "SELECT payload FROM traces WHERE session_id = ? AND type in ('raw_thinking', 'llm_call') " "AND status = 'success' ORDER BY timestamp DESC LIMIT 1", (session_id,), ) row_thought = cur2.fetchone() if row_thought and row_thought["payload"]: try: p_obj = json.loads(row_thought["payload"]) if isinstance(p_obj, dict): if "thought" in p_obj: progress["latest_thought"] = p_obj["thought"] elif ( "response" in p_obj and isinstance(p_obj["response"], list) and p_obj["response"] ): progress["latest_thought"] = p_obj["response"][0].get( "text", "" ) except (ValueError, TypeError, AttributeError): # Unparseable thought payload: omit it. pass cur2.execute( "SELECT name, payload FROM traces WHERE session_id = ? AND type = 'tool' " "AND status = 'success' ORDER BY timestamp DESC LIMIT 1", (session_id,), ) row_action = cur2.fetchone() if row_action: act_name = row_action["name"] act_payload = {} if row_action["payload"]: try: act_payload = json.loads(row_action["payload"]).get( "args", {} ) except (ValueError, TypeError, AttributeError): # Unparseable action payload: show the # bare action name. pass progress["latest_action"] = ( f"{act_name}({act_payload})" if act_payload else act_name ) conn2.close() except Exception as q_err: print( f"Error querying flash progress from data_engine.db: {q_err}", file=sys.stderr, ) else: plan_content = None plan_path = os.path.join( trace_store.TRACES_DIR, session_id, "notes", "task_plan.md" ) if not os.path.exists(plan_path): plan_path = os.path.join(trace_dir, "notes", "task_plan.md") if os.path.exists(plan_path): try: with open(plan_path, encoding="utf-8") as f: plan_content = f.read() except OSError: # Unreadable plan file: progress omits the plan. pass progress["task_plan"] = plan_content else: progress["phase"] = "initializing" progress["status_message"] = "Spawning background runner..." response["progress"] = progress return response elif action == "guidance_history": return { "trace_id": trace_id, "instructions": list_instructions(trace_id, task_status=current_status), } elif action == "pause": if current_status == "pausing": return { "trace_id": trace_id, "status": "pausing", "message": "Pause is still pending; check status for task_paused.", } if current_status not in ("running", "pending", "resuming") or not is_alive: return { "trace_id": trace_id, "status": current_status, "message": "Task is not running.", } request_pause(trace_id) return { "trace_id": trace_id, "status": "pausing", "message": "Pause requested; wait for task_paused and device release.", } elif action == "resume": if current_status != "task_paused" or not is_alive: return { "trace_id": trace_id, "status": current_status, "message": "Task is not paused.", } if instruction: add_instruction(trace_id, instruction, source="mcp") request_resume(trace_id) return { "trace_id": trace_id, "status": "resuming", "message": "Queued for the device again.", } elif action == "stop": if current_status not in ("running", "pending", "pausing", "task_paused", "resuming"): return { "trace_id": trace_id, "status": current_status, "message": f"Cannot stop task because it is already in a terminal state: {current_status}", } # 1. First attempt graceful cancellation via unified Daemon if available stopped_via_daemon = False if os.environ.get("ARTEMIS_STANDALONE") != "1": try: if is_daemon_running(): stopped_via_daemon = stop_task_on_daemon(trace_id) except Exception as exc: # Fall through to direct process termination below. print(f"Daemon stop attempt failed for {trace_id}: {exc}", file=sys.stderr) # 2. Cancel queue reservation if present queue_ticket = status_data.get("queue_ticket") if queue_ticket: try: DeviceExecutionLock.cancel_reservation(queue_ticket) except OSError as exc: # A stale reservation expires on its own; note the failure. print(f"Could not cancel reservation {queue_ticket}: {exc}", file=sys.stderr) # 3. If process PID is missing, try to resolve from active device owners if not pid: try: active_owners = DeviceExecutionLock.get_active_owners() for dev_owner in active_owners.values(): if dev_owner.session_id and str(dev_owner.session_id) == trace_id: pid = dev_owner.pid status_data["pid"] = pid trace_store.write_status(trace_id, status_data) break except OSError as exc: # Without a resolved pid the stop falls back to the # daemon/queue outcome, so surface the probe failure. print(f"Could not resolve owner pid for {trace_id}: {exc}", file=sys.stderr) # 4. If process PID could not be determined and not stopped via daemon or queue if not pid and not stopped_via_daemon and not queue_ticket: return { "trace_id": trace_id, "status": current_status, "message": "Process ID (PID) is missing from the task status. Cannot stop task.", } # 5. If process PID is available, terminate the process tree if pid: try: if sys.platform == "win32": if not process_supervisor.terminate_tree(pid): import psutil if psutil.pid_exists(pid): raise RuntimeError( f"Failed to terminate Windows process tree rooted at {pid}" ) DeviceExecutionLock.cleanup_stale_locks() else: os.kill(pid, signal.SIGTERM) except ProcessLookupError: pass except Exception as e: if not stopped_via_daemon: return { "trace_id": trace_id, "status": current_status, "message": f"Failed to terminate process {pid}: {e}", } trace_store.update_trace_status( trace_id, "cancelled", error="Task aborted by user request." ) return { "trace_id": trace_id, "status": "cancelled", "message": f"Successfully stopped background task '{trace_id}'.", } elif action == "inject_instruction": if not instruction and release_loop: instruction = ( "The user has requested to stop the continuous monitoring loop." " Wrap up gracefully and complete the task." ) if not instruction: return { "trace_id": trace_id, "status": current_status, "message": "Missing required argument 'instruction' for action 'inject_instruction'.", } if current_status not in ("running", "pending", "pausing", "task_paused", "resuming"): return { "trace_id": trace_id, "status": current_status, "message": f"Cannot inject instruction because task is in a terminal state: {current_status}", } if not os.path.exists(trace_dir): return { "trace_id": trace_id, "status": current_status, "message": f"Trace directory not found: {trace_dir}", } try: guidance = add_instruction( trace_id, instruction, release_loop=release_loop, source="mcp", instruction_id=request_id, ) if ( current_status == "task_paused" or (control_dir(trace_id) / "pause.request").exists() ): request_resume(trace_id) suffix = " (with release_loop stop signal)" if release_loop else "" return { "trace_id": trace_id, "status": current_status, "guidance": { **guidance, "status": "consumed" if guidance.get("consumed_at") else "pending", }, "message": f"Guidance accepted for the next safe checkpoint{suffix}; consumption before task completion is not guaranteed. Check guidance_history.", } except Exception as e: return { "trace_id": trace_id, "status": current_status, "message": f"Failed to inject instruction: {e}", } else: return { "trace_id": trace_id, "status": current_status, "message": f"Invalid action '{action}'. Supported actions are 'status', 'pause', 'resume', 'stop', and 'inject_instruction'.", }