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
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user