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
@@ -20,8 +20,10 @@ subprocess stdin isolation against pipe hijacking, and stdout purity.
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@@ -34,6 +36,31 @@ from artemis.utils.logger import get_logger
|
||||
from mcp_server.utils import device_utils, env_utils
|
||||
|
||||
|
||||
def _readline_with_timeout(pipe, timeout: float) -> str | None:
|
||||
"""Read one line from a subprocess pipe with a timeout, portably.
|
||||
|
||||
select.select only supports sockets on Windows, so poll via a reader thread
|
||||
instead of selecting on the pipe handle.
|
||||
"""
|
||||
result: queue.Queue = queue.Queue()
|
||||
|
||||
def _reader():
|
||||
try:
|
||||
result.put(pipe.readline())
|
||||
except Exception:
|
||||
result.put(b"")
|
||||
|
||||
reader = threading.Thread(target=_reader, daemon=True)
|
||||
reader.start()
|
||||
try:
|
||||
line = result.get(timeout=timeout)
|
||||
except queue.Empty:
|
||||
return None
|
||||
if not line:
|
||||
return None
|
||||
return line.decode("utf-8").strip()
|
||||
|
||||
|
||||
def test_mcp_stdio_handshake_immediate_input():
|
||||
"""Verify that MCP server over stdio responds to an immediate initialize request
|
||||
|
||||
@@ -51,6 +78,24 @@ def test_mcp_stdio_handshake_immediate_input():
|
||||
"PYTHONUNBUFFERED": "1",
|
||||
"PATH": os.environ.get("PATH", ""),
|
||||
"PYTHONPATH": project_root,
|
||||
# Windows: the interpreter's socket stack (Winsock/_overlapped),
|
||||
# Path.home(), and tempfile handling need these system variables.
|
||||
**{
|
||||
key: os.environ[key]
|
||||
for key in (
|
||||
"SYSTEMROOT",
|
||||
"SYSTEMDRIVE",
|
||||
"WINDIR",
|
||||
"TEMP",
|
||||
"TMP",
|
||||
"USERPROFILE",
|
||||
"HOMEDRIVE",
|
||||
"HOMEPATH",
|
||||
"APPDATA",
|
||||
"LOCALAPPDATA",
|
||||
)
|
||||
if key in os.environ
|
||||
},
|
||||
},
|
||||
cwd=project_root,
|
||||
)
|
||||
@@ -71,15 +116,7 @@ def test_mcp_stdio_handshake_immediate_input():
|
||||
p.stdin.flush()
|
||||
|
||||
# Read initialize response with a strict timeout
|
||||
start = time.time()
|
||||
init_resp_line = None
|
||||
while time.time() - start < 6.0:
|
||||
import select
|
||||
|
||||
r, _, _ = select.select([p.stdout], [], [], 0.2)
|
||||
if r:
|
||||
init_resp_line = p.stdout.readline().decode("utf-8").strip()
|
||||
break
|
||||
init_resp_line = _readline_with_timeout(p.stdout, 6.0)
|
||||
|
||||
assert init_resp_line is not None, "MCP server failed to respond to initialize request within 6 seconds (deadlock detected)!"
|
||||
init_data = json.loads(init_resp_line)
|
||||
@@ -93,15 +130,7 @@ def test_mcp_stdio_handshake_immediate_input():
|
||||
p.stdin.write(json.dumps(tools_req).encode("utf-8") + b"\n")
|
||||
p.stdin.flush()
|
||||
|
||||
tools_resp_line = None
|
||||
start = time.time()
|
||||
while time.time() - start < 4.0:
|
||||
import select
|
||||
|
||||
r, _, _ = select.select([p.stdout], [], [], 0.2)
|
||||
if r:
|
||||
tools_resp_line = p.stdout.readline().decode("utf-8").strip()
|
||||
break
|
||||
tools_resp_line = _readline_with_timeout(p.stdout, 4.0)
|
||||
|
||||
assert tools_resp_line is not None, "MCP server failed to respond to tools/list request!"
|
||||
tools_data = json.loads(tools_resp_line)
|
||||
|
||||
Reference in new issue
Block a user