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

+50 -11
View File
@@ -36,7 +36,7 @@ from langchain_core.messages import (
from artemis.agents.validator.tool_declarations import (
ASK_EXPLORER_TOOL,
CLICK_SEQUENCE_TOOL,
MobileActionExecutor,
MobileActionExecutor, # Legacy shims below; the loop itself uses McpActionExecutor.
ToolDeclaration,
VALIDATOR_TOOLS_DECLARATION,
capture_screenshot_and_parse_ui,
@@ -46,6 +46,7 @@ from artemis.context import ArtemisContext
from artemis.controllers.unified_controller import UnifiedMobileController
from artemis.data_engine.trace import trace
from artemis.graph.state import State
from artemis.mcp.action_executor import McpActionExecutor
from artemis.services.llm import get_llm
from artemis.utils.logger import get_logger
from artemis.utils.notes import get_note_file_path
@@ -230,6 +231,16 @@ class FailureAnalysisStrategy:
def get_tools(self) -> list:
return VALIDATOR_TOOLS_DECLARATION
def required_tools(self) -> frozenset[str]:
"""Tools this strategy's prompt depends on structurally.
A strategy whose prompt is built around a tool (rather than merely listing it)
must declare it here; strategy selection falls back to the base strategy when
the current backend does not provide every required tool, so the dependent
prompt is never rendered against a backend that cannot satisfy it.
"""
return frozenset()
def get_user_message_suffix(self, pre_screenshot: bool, post_screenshot: bool) -> str:
suffix = (
"Your objective is:\n"
@@ -268,6 +279,11 @@ class TargetDisappearedStrategy(FailureAnalysisStrategy):
tools.append(ASK_EXPLORER_TOOL)
return tools
def required_tools(self) -> frozenset[str]:
# The whole prompt is built around atomic chained execution to defeat turn
# latency, which *is* click_sequence.
return frozenset({"click_sequence"})
def get_user_message_suffix(self, pre_screenshot: bool, post_screenshot: bool) -> str:
return ""
@@ -284,6 +300,9 @@ class PixelTargetDisappearedStrategy(FailureAnalysisStrategy):
tools.append(ASK_EXPLORER_TOOL)
return tools
def required_tools(self) -> frozenset[str]:
return frozenset({"click_sequence"})
def get_user_message_suffix(self, pre_screenshot: bool, post_screenshot: bool) -> str:
return ""
@@ -295,15 +314,33 @@ class FailureAnalyzer:
self.ctx = ctx
self.max_iterations = 15
def _select_strategy(self, error_category: ValidationErrorCategory) -> FailureAnalysisStrategy:
def _select_strategy(
self,
error_category: ValidationErrorCategory,
available_tools: frozenset[str] | None = None,
) -> FailureAnalysisStrategy:
if error_category == ValidationErrorCategory.PIXEL_TARGET_DISAPPEARED:
logger.info("Routing failure analysis to specialized PixelTargetDisappearedStrategy.")
return PixelTargetDisappearedStrategy()
if error_category == ValidationErrorCategory.TARGET_DISAPPEARED:
strategy: FailureAnalysisStrategy = PixelTargetDisappearedStrategy()
elif error_category == ValidationErrorCategory.TARGET_DISAPPEARED:
logger.info("Routing failure analysis to specialized TargetDisappearedStrategy.")
return TargetDisappearedStrategy()
logger.info("Routing failure analysis to DefaultAnalysisStrategy.")
return FailureAnalysisStrategy()
strategy = TargetDisappearedStrategy()
else:
logger.info("Routing failure analysis to DefaultAnalysisStrategy.")
strategy = FailureAnalysisStrategy()
# Capability gating: a strategy and its prompt are one unit. If the backend
# cannot satisfy the tools the prompt is built around, fall back to the base
# strategy so the dependent prompt is never rendered. `None` = no gating.
if available_tools is not None:
missing = strategy.required_tools() - available_tools
if missing:
logger.warning(
f"Strategy {type(strategy).__name__} requires unavailable tool(s)"
f" {sorted(missing)}; falling back to base FailureAnalysisStrategy."
)
return FailureAnalysisStrategy()
return strategy
def _get_active_subgoal_hash(self) -> str:
if not self.ctx.data_engine:
@@ -363,7 +400,12 @@ class FailureAnalyzer:
if not pre_screenshot:
raise ValueError("Failure analysis requires valid screenshots.")
strategy = self._select_strategy(error_category)
controller = UnifiedMobileController(self.ctx)
executor = McpActionExecutor(self.ctx, controller)
strategy = self._select_strategy(
error_category, available_tools=frozenset(executor.action_tool_names)
)
prompt_template_name = strategy.get_prompt_template_name()
tools_declaration = strategy.get_tools()
@@ -372,9 +414,6 @@ class FailureAnalyzer:
except Exception:
llm = get_llm(self.ctx, name="validator")
controller = UnifiedMobileController(self.ctx)
executor = MobileActionExecutor(self.ctx, controller)
# Pre-Action and Post-Action XML formatting
pre_xml_list_str = "Not available."
if hasattr(state, "latest_ui_hierarchy") and state.latest_ui_hierarchy: