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

+44 -25
View File
@@ -114,6 +114,30 @@ class OperatorNode:
logger.error(f"Failed to load operator prompts: {e}")
self.prompts = {}
def _available_device_actions(self) -> frozenset[str]:
"""Device actions the installed actuator backend provides.
Without an explicit ``ctx.actuator`` the full manifest set applies (the
default AdbActuator implements everything), so behavior is unchanged until a
partial backend is actually installed.
"""
from artemis.mcp.action_manifest import (
OPTIONAL_ACTIONS,
REQUIRED_ACTIONS,
available_device_actions,
)
actuator = getattr(self.ctx, "actuator", None)
if actuator is not None and callable(getattr(actuator, "capabilities", None)):
try:
extension_names = frozenset(
e.name for e in actuator.extensions() if "operator" in e.targets
)
return available_device_actions(actuator) | extension_names
except Exception as e:
logger.warning(f"Failed to read actuator capabilities: {e}")
return REQUIRED_ACTIONS | OPTIONAL_ACTIONS
def _check_infinite_loop(self, state: State):
subagent_calls = state.subagent_calls or []
if len(subagent_calls) >= 3:
@@ -386,15 +410,9 @@ class OperatorNode:
current_messages.append(response)
action_tool_names = [
"click",
"input_text",
"swipe",
"press_key",
"manage_app",
"wait_for_delay",
"long_press",
]
# Manifest-driven, so a backend's extension actions are classified as
# turn-ending device actions without touching this file.
action_tool_names = sorted(self._available_device_actions())
def normalize_name(name: str) -> str:
return name.split(":")[-1] if ":" in name else name
@@ -804,23 +822,24 @@ class OperatorNode:
current_step_num = len(steps) + 1
# 5. Prepare Tools
click_tool = self._get_click_tool()
input_text_tool = self._get_input_text_tool()
swipe_tool = self._get_swipe_tool()
press_key_tool = self._get_press_key_tool()
manage_app_tool = self._get_manage_app_tool()
wait_for_delay_tool = self._get_wait_for_delay_tool()
long_press_tool = self._get_long_press_tool()
# 5. Prepare Tools. Device-action declarations are assembled against the
# installed actuator backend's capabilities: an action the backend does not
# implement is simply never declared (and the prompt assembly drops its
# teaching segments in lockstep).
available_actions = self._available_device_actions()
action_tool_factories = {
"click": self._get_click_tool,
"input_text": self._get_input_text_tool,
"swipe": self._get_swipe_tool,
"press_key": self._get_press_key_tool,
"manage_app": self._get_manage_app_tool,
"wait_for_delay": self._get_wait_for_delay_tool,
"long_press": self._get_long_press_tool,
}
all_tools = [
click_tool,
input_text_tool,
swipe_tool,
press_key_tool,
manage_app_tool,
wait_for_delay_tool,
long_press_tool,
factory()
for name, factory in action_tool_factories.items()
if name in available_actions
] + self.tools
if self._has_checker_feedback():