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

+58
View File
@@ -19,6 +19,29 @@ from typing import Any
from artemis.config import PAUSE_FILE
def _pid_exists(pid: int) -> bool:
"""Best-effort liveness probe; returns True when liveness cannot be determined.
psutil is preferred because on Windows ``os.kill(pid, 0)`` does not report
dead pids reliably.
"""
try:
import psutil
return psutil.pid_exists(pid)
except Exception:
pass
try:
import os
os.kill(pid, 0)
return True
except PermissionError:
return True
except (ProcessLookupError, OSError):
return False
class ServerState:
"""Encapsulates all runtime states of the debug server."""
@@ -40,6 +63,12 @@ class ServerState:
self.cancelled_session_ids: set[str] = set()
self.startup_progress: dict[str, list[dict[str, Any]]] = {}
# Concurrent task executions keyed by session_id. Each value holds
# {"process", "device_id", "goal", "profile"}. `current_process` /
# `active_session_id` mirror the most recently launched run for
# backward compatibility with single-task consumers.
self.active_runs: dict[str, dict[str, Any]] = {}
# Unified single source of truth for task queue
self.queue_items: list[dict[str, Any]] = []
self._wake_event: asyncio.Event | None = None
@@ -101,8 +130,37 @@ class ServerState:
def queue_goals(self, val: list[Any]):
pass
def prune_finished_runs(self) -> None:
"""Drop finished entries from active_runs.
Besides reaped processes (returncode set), also drops entries whose pid no
longer exists: if a run coroutine dies without reaping its child, the entry
must not permanently block the scheduler.
"""
for sid, run in list(self.active_runs.items()):
proc = run.get("process")
if proc is None or proc.returncode is not None:
self.active_runs.pop(sid, None)
continue
pid = getattr(proc, "pid", None)
if pid and not _pid_exists(pid):
self.active_runs.pop(sid, None)
@property
def busy_device_ids(self) -> set[str]:
"""Device serials currently owned by an in-flight run."""
self.prune_finished_runs()
return {
str(run["device_id"])
for run in self.active_runs.values()
if run.get("device_id")
}
@property
def is_running(self) -> bool:
self.prune_finished_runs()
if self.active_runs:
return True
has_proc = False
if self.current_process is not None:
if self.current_process.returncode is not None: