Stabilize task execution and UI lifecycle
This commit is contained in:
1 parent
c78c201e94
commit
4927c4f0b9
35 files changed
+6680
-239
No files matched your search
@@ -63,7 +63,8 @@ def write_ipc_port(port: int) -> Path:
|
||||
|
||||
|
||||
def clear_ipc_port() -> None:
|
||||
"""Remove IPC port synchronization state file."""
|
||||
"""Remove IPC port synchronization state from the process and filesystem."""
|
||||
os.environ.pop(ENV_ARTEMIS_IPC_PORT, None)
|
||||
port_file = get_ipc_port_file()
|
||||
if port_file.exists():
|
||||
try:
|
||||
|
||||
@@ -21,6 +21,7 @@ import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
@@ -73,6 +74,21 @@ class EmulatorManager:
|
||||
self._lock = asyncio.Lock()
|
||||
self._reader_thread: threading.Thread | None = None
|
||||
|
||||
@staticmethod
|
||||
def _subprocess_creation_kwargs() -> dict[str, Any]:
|
||||
"""Isolate the emulator from the UI server's console signals.
|
||||
|
||||
``start_new_session`` is POSIX-only and is ignored by Python's Windows
|
||||
subprocess implementation. Windows therefore needs explicit creation
|
||||
flags so Ctrl+C or shutdown signals sent to the UI server do not also
|
||||
terminate the emulator. The Linux/macOS behavior remains unchanged.
|
||||
"""
|
||||
if sys.platform == "win32":
|
||||
return {
|
||||
"creationflags": (subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW)
|
||||
}
|
||||
return {"start_new_session": True}
|
||||
|
||||
def _locate_emulator(self) -> str | None:
|
||||
"""Find the emulator binary path from PATH or standard SDK environments."""
|
||||
resolved = toolchain.resolve("emulator")
|
||||
@@ -191,7 +207,7 @@ class EmulatorManager:
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
start_new_session=True,
|
||||
**self._subprocess_creation_kwargs(),
|
||||
)
|
||||
self._proc = proc
|
||||
now = time.time()
|
||||
|
||||
@@ -24,7 +24,7 @@ import time
|
||||
from typing import Any
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from artemis.config import read_ipc_port, settings
|
||||
from artemis.config import get_ipc_port_file, read_ipc_port, settings
|
||||
from artemis.context import ArtemisContext
|
||||
from artemis.data_engine.models import (
|
||||
BackgroundTaskRecord,
|
||||
@@ -82,17 +82,80 @@ class DataEngine:
|
||||
# Background tasks are written directly to SQLite storage
|
||||
|
||||
self.ipc_socket = None
|
||||
self._ipc_socket_lock = threading.Lock()
|
||||
self._ipc_shutdown = False
|
||||
with self._ipc_socket_lock:
|
||||
self._connect_ipc_locked()
|
||||
|
||||
ipc_port = read_ipc_port()
|
||||
if ipc_port:
|
||||
@staticmethod
|
||||
def _ipc_port_candidates() -> list[int]:
|
||||
"""Return inherited and freshly published UI ports, newest file included.
|
||||
|
||||
A worker inherits ``ARTEMIS_IPC_PORT`` when it starts. If the Windows UI
|
||||
server restarts, that environment value is permanently stale while the
|
||||
shared port file is updated by the new server.
|
||||
"""
|
||||
candidates: list[int] = []
|
||||
inherited_or_file_port = read_ipc_port()
|
||||
if inherited_or_file_port:
|
||||
candidates.append(inherited_or_file_port)
|
||||
|
||||
port_file = get_ipc_port_file()
|
||||
if port_file.exists():
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.connect(("127.0.0.1", ipc_port))
|
||||
s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||
self.ipc_socket = s
|
||||
file_value = port_file.read_text(encoding="utf-8").strip()
|
||||
if file_value.isdigit() and int(file_value) not in candidates:
|
||||
candidates.append(int(file_value))
|
||||
except OSError as exc:
|
||||
logger.debug(f"Could not read refreshed IPC port from {port_file}: {exc}")
|
||||
return candidates
|
||||
|
||||
def _connect_ipc_locked(self) -> bool:
|
||||
"""Connect to the UI event bridge while holding ``_ipc_socket_lock``."""
|
||||
if self._ipc_shutdown:
|
||||
return False
|
||||
if self.ipc_socket is not None:
|
||||
return True
|
||||
|
||||
for ipc_port in self._ipc_port_candidates():
|
||||
try:
|
||||
sock = socket.create_connection(("127.0.0.1", ipc_port), timeout=1.0)
|
||||
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||
sock.settimeout(None)
|
||||
self.ipc_socket = sock
|
||||
logger.info(f"Connected to IPC server on port {ipc_port} with TCP_NODELAY")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to IPC server on port {ipc_port}: {e}")
|
||||
return True
|
||||
except OSError as exc:
|
||||
logger.warning(f"Failed to connect to IPC server on port {ipc_port}: {exc}")
|
||||
|
||||
self.ipc_socket = None
|
||||
return False
|
||||
|
||||
def _send_ipc_event(self, event_type: str, data: Any) -> None:
|
||||
"""Send one complete event, reconnecting once if a stale socket is found.
|
||||
|
||||
Publishing can originate from LLM callbacks and background worker threads.
|
||||
Serializing socket access keeps newline-delimited JSON frames from being
|
||||
interleaved on Windows and prevents the event that detects a dead socket
|
||||
from being silently lost.
|
||||
"""
|
||||
payload = json.dumps({"event_type": event_type, "data": data}, default=str) + "\n"
|
||||
encoded_payload = payload.encode("utf-8")
|
||||
|
||||
with self._ipc_socket_lock:
|
||||
for _attempt in range(2):
|
||||
if not self._connect_ipc_locked():
|
||||
return
|
||||
try:
|
||||
self.ipc_socket.sendall(encoded_payload)
|
||||
return
|
||||
except OSError as exc:
|
||||
logger.warning(f"IPC send failed; reconnecting: {exc}")
|
||||
try:
|
||||
self.ipc_socket.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.ipc_socket = None
|
||||
|
||||
@property
|
||||
def base_dir(self) -> Path:
|
||||
@@ -144,28 +207,7 @@ class DataEngine:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Auto-reconnect to IPC socket if disconnected or not yet connected
|
||||
if not getattr(self, "ipc_socket", None):
|
||||
ipc_port = read_ipc_port()
|
||||
if ipc_port:
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.connect(("127.0.0.1", ipc_port))
|
||||
s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||
self.ipc_socket = s
|
||||
except Exception:
|
||||
self.ipc_socket = None
|
||||
|
||||
if getattr(self, "ipc_socket", None):
|
||||
try:
|
||||
payload = json.dumps({"event_type": event_type, "data": data}, default=str) + "\n"
|
||||
self.ipc_socket.sendall(payload.encode("utf-8"))
|
||||
except Exception:
|
||||
try:
|
||||
self.ipc_socket.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.ipc_socket = None
|
||||
self._send_ipc_event(event_type, data)
|
||||
|
||||
def start_session(self, goal: str, device_info: dict[str, Any] | None = None) -> UUID:
|
||||
"""Start a new session."""
|
||||
@@ -601,11 +643,14 @@ class DataEngine:
|
||||
if thread.is_alive():
|
||||
await asyncio.to_thread(thread.join)
|
||||
|
||||
if getattr(self, "ipc_socket", None):
|
||||
try:
|
||||
self.ipc_socket.close()
|
||||
except Exception:
|
||||
pass
|
||||
with self._ipc_socket_lock:
|
||||
self._ipc_shutdown = True
|
||||
if self.ipc_socket is not None:
|
||||
try:
|
||||
self.ipc_socket.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.ipc_socket = None
|
||||
|
||||
logger.info("DataEngine shutdown complete. All data persisted.")
|
||||
|
||||
|
||||
@@ -34,6 +34,18 @@ import typer
|
||||
logger = get_logger(__name__)
|
||||
console = Console()
|
||||
|
||||
ARTEMIS_MCP_TOOLS = (
|
||||
"mobile_run_task",
|
||||
"mobile_manage_task",
|
||||
"mobile_get_device_state",
|
||||
"mobile_inspect_trace",
|
||||
)
|
||||
|
||||
|
||||
def _eager_tools_config() -> dict[str, dict[str, bool]]:
|
||||
"""Return the eager-loading extension used by clients that support it."""
|
||||
return {tool_name: {"eager": True} for tool_name in ARTEMIS_MCP_TOOLS}
|
||||
|
||||
|
||||
def _get_vscode_user_dir() -> Path:
|
||||
"""Returns the platform-specific VS Code user configuration directory."""
|
||||
@@ -51,32 +63,94 @@ def _get_config_snippet(client: str, python_exe: str, project_root: str) -> dict
|
||||
config_body = {
|
||||
"command": python_exe,
|
||||
"args": ["-m", "mcp_server"],
|
||||
"cwd": project_root,
|
||||
"env": {
|
||||
"PYTHONUNBUFFERED": "1",
|
||||
"PYTHONPATH": project_root,
|
||||
},
|
||||
}
|
||||
config_with_cwd = {**config_body, "cwd": project_root}
|
||||
|
||||
if client in ("antigravity", "jetski"):
|
||||
if client == "antigravity":
|
||||
return {
|
||||
"mcpServers": {
|
||||
"artemis": {
|
||||
**config_body,
|
||||
"tools": {
|
||||
"mobile_run_task": {"eager": True},
|
||||
"mobile_manage_task": {"eager": True},
|
||||
"mobile_get_device_state": {"eager": True},
|
||||
"mobile_inspect_trace": {"eager": True},
|
||||
},
|
||||
**config_with_cwd,
|
||||
# This is the documented way to expose every tool. Avoid
|
||||
# proprietary eager fields in the current config so a
|
||||
# strict parser can always load the server.
|
||||
"disabledTools": [],
|
||||
}
|
||||
}
|
||||
}
|
||||
elif client == "jetski":
|
||||
return {
|
||||
"mcpServers": {
|
||||
"artemis": {
|
||||
**config_with_cwd,
|
||||
"disabledTools": [],
|
||||
# Retain the legacy Jetski eager extension only in its
|
||||
# legacy config file. Current Antigravity gets the strict,
|
||||
# documented configuration above as a safe fallback.
|
||||
"tools": _eager_tools_config(),
|
||||
}
|
||||
}
|
||||
}
|
||||
elif client == "openclaw":
|
||||
return {"plugins": {"artemis_mcp": {"enabled": True, "type": "mcp", "server": config_body}}}
|
||||
return {
|
||||
"mcp": {
|
||||
"servers": {
|
||||
"artemis": {
|
||||
**config_with_cwd,
|
||||
"enabled": True,
|
||||
"connectionTimeoutMs": 120000,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
elif client == "codex":
|
||||
return {"mcp_servers": {"artemis": config_body}}
|
||||
else: # cursor, windsurf, claude, vscode, cline, roo, generic
|
||||
return {
|
||||
"mcp_servers": {
|
||||
"artemis": {
|
||||
**config_with_cwd,
|
||||
# Codex has no eager/deferred MCP setting. Requiring the
|
||||
# server makes it initialize during host startup, while an
|
||||
# explicit allow-list guarantees all Artemis tools are
|
||||
# registered. The host may still present schemas through
|
||||
# tool search to control context size.
|
||||
"enabled": True,
|
||||
"required": True,
|
||||
"startup_timeout_sec": 120,
|
||||
"enabled_tools": list(ARTEMIS_MCP_TOOLS),
|
||||
}
|
||||
}
|
||||
}
|
||||
elif client == "vscode":
|
||||
return {"servers": {"artemis": {**config_with_cwd, "type": "stdio"}}}
|
||||
elif client in ("claude", "claude_code", "claude_desktop"):
|
||||
return {"mcpServers": {"artemis": {**config_body, "type": "stdio"}}}
|
||||
elif client == "cline":
|
||||
return {
|
||||
"mcpServers": {
|
||||
"artemis": {
|
||||
**config_body,
|
||||
"disabled": False,
|
||||
"autoApprove": [],
|
||||
"timeout": 120,
|
||||
}
|
||||
}
|
||||
}
|
||||
elif client in ("roo", "roo_code"):
|
||||
return {
|
||||
"mcpServers": {
|
||||
"artemis": {
|
||||
**config_body,
|
||||
"disabled": False,
|
||||
"alwaysAllow": [],
|
||||
"timeout": 120,
|
||||
}
|
||||
}
|
||||
}
|
||||
else: # cursor, windsurf, generic
|
||||
return {"mcpServers": {"artemis": config_body}}
|
||||
|
||||
|
||||
@@ -100,7 +174,13 @@ def _parse_json_lenient(text: str) -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
def _merge_json_file(file_path: Path, server_name: str, server_config: dict, key_name: str = "mcpServers") -> bool:
|
||||
def _merge_json_file(
|
||||
file_path: Path,
|
||||
server_name: str,
|
||||
server_config: dict,
|
||||
key_name: str | tuple[str, ...] = "mcpServers",
|
||||
remove_paths: tuple[tuple[str, ...], ...] = (),
|
||||
) -> bool:
|
||||
"""Merges server configuration into a target JSON file without overwriting existing servers."""
|
||||
try:
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -117,10 +197,25 @@ def _merge_json_file(file_path: Path, server_name: str, server_config: dict, key
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if key_name not in data or not isinstance(data.get(key_name), dict):
|
||||
data[key_name] = {}
|
||||
for remove_path in remove_paths:
|
||||
parent = data
|
||||
for part in remove_path[:-1]:
|
||||
child = parent.get(part)
|
||||
if not isinstance(child, dict):
|
||||
parent = {}
|
||||
break
|
||||
parent = child
|
||||
if parent:
|
||||
parent.pop(remove_path[-1], None)
|
||||
|
||||
data[key_name][server_name] = server_config
|
||||
key_path = (key_name,) if isinstance(key_name, str) else key_name
|
||||
target = data
|
||||
for part in key_path:
|
||||
if not isinstance(target.get(part), dict):
|
||||
target[part] = {}
|
||||
target = target[part]
|
||||
|
||||
target[server_name] = server_config
|
||||
file_path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
||||
return True
|
||||
except Exception as e:
|
||||
@@ -141,6 +236,15 @@ def _codex_toml_block(server_config: dict) -> str:
|
||||
f"args = {args}",
|
||||
f"cwd = {cwd}",
|
||||
]
|
||||
for key in ("enabled", "required"):
|
||||
if key in server_config:
|
||||
lines.append(f"{key} = {'true' if server_config[key] else 'false'}")
|
||||
if "startup_timeout_sec" in server_config:
|
||||
lines.append(f"startup_timeout_sec = {int(server_config['startup_timeout_sec'])}")
|
||||
if "enabled_tools" in server_config:
|
||||
lines.append(
|
||||
f"enabled_tools = {json.dumps(server_config['enabled_tools'], ensure_ascii=False)}"
|
||||
)
|
||||
if env:
|
||||
lines.extend(["", "[mcp_servers.artemis.env]"])
|
||||
for key, value in env.items():
|
||||
@@ -380,12 +484,22 @@ def install_mcp_config(client: str, python_exe: str, project_root: str) -> list[
|
||||
for target in targets:
|
||||
snippet = _get_config_snippet(target, python_exe, project_root)
|
||||
if target in ("antigravity", "jetski"):
|
||||
server_cfg = snippet["mcpServers"]["artemis"]
|
||||
current_server_cfg = _get_config_snippet("antigravity", python_exe, project_root)[
|
||||
"mcpServers"
|
||||
]["artemis"]
|
||||
legacy_server_cfg = _get_config_snippet("jetski", python_exe, project_root)[
|
||||
"mcpServers"
|
||||
]["artemis"]
|
||||
jetski_path = Path.home() / ".gemini" / "jetski" / "mcp_config.json"
|
||||
antigravity_legacy_path = (
|
||||
Path.home() / ".gemini" / "antigravity" / "mcp_config.json"
|
||||
)
|
||||
config_path = Path.home() / ".gemini" / "config" / "mcp_config.json"
|
||||
if _merge_json_file(jetski_path, "artemis", server_cfg):
|
||||
if _merge_json_file(jetski_path, "artemis", legacy_server_cfg):
|
||||
installed_paths.append(str(jetski_path))
|
||||
if _merge_json_file(config_path, "artemis", server_cfg):
|
||||
if _merge_json_file(antigravity_legacy_path, "artemis", current_server_cfg):
|
||||
installed_paths.append(str(antigravity_legacy_path))
|
||||
if _merge_json_file(config_path, "artemis", current_server_cfg):
|
||||
installed_paths.append(str(config_path))
|
||||
elif target in ("claude", "claude_code", "claude_desktop"):
|
||||
server_cfg = snippet["mcpServers"]["artemis"]
|
||||
@@ -414,24 +528,53 @@ def install_mcp_config(client: str, python_exe: str, project_root: str) -> list[
|
||||
if _merge_json_file(windsurf_path, "artemis", server_cfg):
|
||||
installed_paths.append(str(windsurf_path))
|
||||
elif target == "vscode":
|
||||
server_cfg = snippet["mcpServers"]["artemis"]
|
||||
server_cfg = snippet["servers"]["artemis"]
|
||||
vscode_path = _get_vscode_user_dir() / "mcp.json"
|
||||
if _merge_json_file(vscode_path, "artemis", server_cfg):
|
||||
copilot_path = Path.home() / ".copilot" / "mcp-config.json"
|
||||
if _merge_json_file(vscode_path, "artemis", server_cfg, key_name="servers"):
|
||||
installed_paths.append(str(vscode_path))
|
||||
if _merge_json_file(copilot_path, "artemis", server_cfg, key_name="servers"):
|
||||
installed_paths.append(str(copilot_path))
|
||||
elif target == "cline":
|
||||
server_cfg = snippet["mcpServers"]["artemis"]
|
||||
cline_path = _get_vscode_user_dir() / "globalStorage" / "saoudrizwan.claude-dev" / "settings" / "cline_mcp_settings.json"
|
||||
if _merge_json_file(cline_path, "artemis", server_cfg):
|
||||
installed_paths.append(str(cline_path))
|
||||
cline_paths = (
|
||||
Path.home() / ".cline" / "data" / "settings" / "cline_mcp_settings.json",
|
||||
Path.home() / ".cline" / "mcp.json",
|
||||
_get_vscode_user_dir()
|
||||
/ "globalStorage"
|
||||
/ "saoudrizwan.claude-dev"
|
||||
/ "settings"
|
||||
/ "cline_mcp_settings.json",
|
||||
)
|
||||
for cline_path in cline_paths:
|
||||
if _merge_json_file(cline_path, "artemis", server_cfg):
|
||||
installed_paths.append(str(cline_path))
|
||||
elif target in ("roo", "roo_code"):
|
||||
server_cfg = snippet["mcpServers"]["artemis"]
|
||||
roo_path = _get_vscode_user_dir() / "globalStorage" / "rooveterinaryinc.roo-cline" / "settings" / "cline_mcp_settings.json"
|
||||
if _merge_json_file(roo_path, "artemis", server_cfg):
|
||||
installed_paths.append(str(roo_path))
|
||||
roo_settings_dir = (
|
||||
_get_vscode_user_dir()
|
||||
/ "globalStorage"
|
||||
/ "rooveterinaryinc.roo-cline"
|
||||
/ "settings"
|
||||
)
|
||||
roo_paths = (
|
||||
roo_settings_dir / "mcp_settings.json",
|
||||
roo_settings_dir / "cline_mcp_settings.json",
|
||||
Path(project_root) / ".roo" / "mcp.json",
|
||||
)
|
||||
for roo_path in roo_paths:
|
||||
if _merge_json_file(roo_path, "artemis", server_cfg):
|
||||
installed_paths.append(str(roo_path))
|
||||
elif target == "openclaw":
|
||||
plugin_cfg = snippet["plugins"]["artemis_mcp"]
|
||||
server_cfg = snippet["mcp"]["servers"]["artemis"]
|
||||
openclaw_path = Path.home() / ".openclaw" / "openclaw.json"
|
||||
if _merge_json_file(openclaw_path, "artemis_mcp", plugin_cfg, key_name="plugins"):
|
||||
if _merge_json_file(
|
||||
openclaw_path,
|
||||
"artemis",
|
||||
server_cfg,
|
||||
key_name=("mcp", "servers"),
|
||||
remove_paths=(("plugins", "artemis_mcp"),),
|
||||
):
|
||||
installed_paths.append(str(openclaw_path))
|
||||
elif target == "codex":
|
||||
server_cfg = snippet["mcp_servers"]["artemis"]
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
|
||||
"""UI Launch and Web Console CLI Command (artemis ui)."""
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from typing import Annotated
|
||||
@@ -25,6 +27,27 @@ from rich.panel import Panel
|
||||
import typer
|
||||
|
||||
|
||||
def _showcase_build_required(showcase_dir: Path) -> bool:
|
||||
"""Return whether the compiled Angular app is missing or older than its inputs."""
|
||||
base_dist = showcase_dir / "dist"
|
||||
candidates = [
|
||||
base_dist / "frontend" / "browser" / "index.html",
|
||||
base_dist / "browser" / "index.html",
|
||||
base_dist / "frontend" / "index.html",
|
||||
base_dist / "index.html",
|
||||
]
|
||||
built_indexes = [path for path in candidates if path.exists()]
|
||||
if not built_indexes:
|
||||
return True
|
||||
|
||||
build_time = max(path.stat().st_mtime for path in built_indexes)
|
||||
input_paths = [showcase_dir / "package.json", showcase_dir / "angular.json"]
|
||||
source_dir = showcase_dir / "src"
|
||||
if source_dir.exists():
|
||||
input_paths.extend(path for path in source_dir.rglob("*") if path.is_file())
|
||||
return any(path.exists() and path.stat().st_mtime > build_time for path in input_paths)
|
||||
|
||||
|
||||
def _poll_and_open_browser(url: str, stop_event: threading.Event, timeout: float = 15.0) -> None:
|
||||
"""Poll the UI server until it responds, then launch the user's default browser."""
|
||||
start = time.time()
|
||||
@@ -74,7 +97,11 @@ def ui_command(
|
||||
f"📱 [bold green]Showcase UI & Onboarding:[/bold green] [cyan]{local_url}[/cyan]\n"
|
||||
f"🛠️ [bold magenta]Admin Debug Console:[/bold magenta] [cyan]{local_url}/admin[/cyan]\n"
|
||||
f"📖 [dim]API Documentation:[/dim] [dim]{local_url}/docs[/dim]\n\n"
|
||||
f"[dim]Press Ctrl+C to stop the server.[/dim]"
|
||||
+ (
|
||||
"[dim]Press Ctrl+C to stop while idle; Ctrl+Break forces shutdown during a task.[/dim]"
|
||||
if sys.platform == "win32"
|
||||
else "[dim]Press Ctrl+C to stop the server.[/dim]"
|
||||
)
|
||||
)
|
||||
console.print(Panel(msg, title="🚀 Web Server Active", border_style="cyan", expand=False))
|
||||
# Ensure .env exists in workspace root
|
||||
@@ -94,22 +121,15 @@ def ui_command(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
base_dist = ROOT_DIR / "apps" / "showcase_ui" / "dist"
|
||||
candidates = [
|
||||
base_dist / "frontend" / "browser" / "index.html",
|
||||
base_dist / "browser" / "index.html",
|
||||
base_dist / "frontend" / "index.html",
|
||||
base_dist / "index.html",
|
||||
]
|
||||
if not any(p.exists() for p in candidates):
|
||||
showcase_dir = ROOT_DIR / "apps" / "showcase_ui"
|
||||
if _showcase_build_required(showcase_dir):
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
if shutil.which("npm"):
|
||||
console.print(
|
||||
" [yellow]🎨 Showcase UI build not found. Compiling Angular Showcase UI...[/yellow]"
|
||||
" [yellow]🎨 Showcase UI sources changed. Compiling Angular Showcase UI...[/yellow]"
|
||||
)
|
||||
showcase_dir = ROOT_DIR / "apps" / "showcase_ui"
|
||||
try:
|
||||
subprocess.run(["npm", "install", "--silent"], cwd=showcase_dir, check=True)
|
||||
subprocess.run(["npm", "run", "build"], cwd=showcase_dir, check=True)
|
||||
@@ -127,10 +147,9 @@ def ui_command(
|
||||
t.start()
|
||||
|
||||
try:
|
||||
import uvicorn
|
||||
from apps.admin_console.server import app
|
||||
from apps.admin_console.server import run_ui_server
|
||||
|
||||
uvicorn.run(app, host=host, port=port, reload=reload)
|
||||
run_ui_server(host=host, port=port, reload=reload)
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[yellow]🛑 UI Server stopped.[/yellow]")
|
||||
finally:
|
||||
|
||||
@@ -14,9 +14,12 @@
|
||||
|
||||
"""Artemis cross-platform runtime and process supervisor subsystem."""
|
||||
|
||||
from artemis.runtime.device_lock import DeviceBusyError, DeviceExecutionLock
|
||||
from artemis.runtime.supervisor import ProcessSupervisor, process_supervisor
|
||||
|
||||
__all__ = [
|
||||
"DeviceBusyError",
|
||||
"DeviceExecutionLock",
|
||||
"ProcessSupervisor",
|
||||
"process_supervisor",
|
||||
]
|
||||
@@ -0,0 +1,189 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Cross-process exclusive ownership for a local mobile device."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from artemis.config.paths import get_temp_dir
|
||||
|
||||
|
||||
class DeviceBusyError(RuntimeError):
|
||||
"""Raised when another Artemis process already owns a device."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeviceLockOwner:
|
||||
pid: int
|
||||
process_created_at: float
|
||||
token: str
|
||||
device_id: str
|
||||
description: str
|
||||
acquired_at: str
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: dict) -> DeviceLockOwner:
|
||||
return cls(
|
||||
pid=int(value["pid"]),
|
||||
process_created_at=float(value["process_created_at"]),
|
||||
token=str(value["token"]),
|
||||
device_id=str(value["device_id"]),
|
||||
description=str(value.get("description", "unknown task")),
|
||||
acquired_at=str(value.get("acquired_at", "unknown time")),
|
||||
)
|
||||
|
||||
|
||||
class DeviceExecutionLock:
|
||||
"""A PID-aware lock shared by UI, CLI, and MCP task processes.
|
||||
|
||||
Atomic file creation provides the inter-process exclusion. PID creation
|
||||
time prevents a stale lock from blocking a device after a process exits or
|
||||
after the operating system reuses the same PID.
|
||||
"""
|
||||
|
||||
_MALFORMED_LOCK_GRACE_SECONDS = 5.0
|
||||
|
||||
def __init__(self, device_id: str, description: str = "Artemis task"):
|
||||
self.device_id = device_id
|
||||
self.description = description
|
||||
self.token = uuid.uuid4().hex
|
||||
digest = hashlib.sha256(device_id.encode("utf-8", errors="replace")).hexdigest()[:20]
|
||||
self.path = get_temp_dir("device-locks") / f"device-{digest}.lock"
|
||||
self._acquired = False
|
||||
|
||||
@staticmethod
|
||||
def _current_process_created_at() -> float:
|
||||
try:
|
||||
import psutil
|
||||
|
||||
return float(psutil.Process(os.getpid()).create_time())
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
@staticmethod
|
||||
def _owner_is_alive(owner: DeviceLockOwner) -> bool:
|
||||
try:
|
||||
import psutil
|
||||
|
||||
process = psutil.Process(owner.pid)
|
||||
if not process.is_running():
|
||||
return False
|
||||
if owner.process_created_at <= 0:
|
||||
return True
|
||||
return abs(process.create_time() - owner.process_created_at) < 1.0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _read_owner(path: Path) -> DeviceLockOwner | None:
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
return DeviceLockOwner.from_dict(payload)
|
||||
except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
def _remove_stale_lock(self) -> bool:
|
||||
owner = self._read_owner(self.path)
|
||||
if owner is not None:
|
||||
if self._owner_is_alive(owner):
|
||||
return False
|
||||
else:
|
||||
try:
|
||||
if time.time() - self.path.stat().st_mtime < self._MALFORMED_LOCK_GRACE_SECONDS:
|
||||
return False
|
||||
except OSError:
|
||||
return True
|
||||
|
||||
try:
|
||||
self.path.unlink(missing_ok=True)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
def acquire(self) -> None:
|
||||
owner_payload = {
|
||||
"pid": os.getpid(),
|
||||
"process_created_at": self._current_process_created_at(),
|
||||
"token": self.token,
|
||||
"device_id": self.device_id,
|
||||
"description": self.description,
|
||||
"acquired_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
|
||||
for _ in range(4):
|
||||
try:
|
||||
fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||
except FileExistsError:
|
||||
if self._remove_stale_lock():
|
||||
continue
|
||||
owner = self._read_owner(self.path)
|
||||
owner_text = (
|
||||
f"PID {owner.pid} ({owner.description}, since {owner.acquired_at})"
|
||||
if owner is not None
|
||||
else "another process"
|
||||
)
|
||||
raise DeviceBusyError(
|
||||
f"Device '{self.device_id}' is already controlled by {owner_text}. "
|
||||
"Wait for that task to finish or stop it before starting another task."
|
||||
)
|
||||
else:
|
||||
try:
|
||||
os.write(fd, json.dumps(owner_payload, ensure_ascii=False).encode("utf-8"))
|
||||
finally:
|
||||
os.close(fd)
|
||||
self._acquired = True
|
||||
return
|
||||
|
||||
raise DeviceBusyError(f"Could not acquire the execution lock for '{self.device_id}'.")
|
||||
|
||||
def release(self) -> None:
|
||||
if not self._acquired:
|
||||
return
|
||||
try:
|
||||
owner = self._read_owner(self.path)
|
||||
if owner is not None and owner.token == self.token:
|
||||
self.path.unlink(missing_ok=True)
|
||||
finally:
|
||||
self._acquired = False
|
||||
|
||||
@classmethod
|
||||
def cleanup_stale_locks(cls) -> int:
|
||||
"""Remove lock files whose owning process is no longer alive."""
|
||||
removed = 0
|
||||
lock_dir = get_temp_dir("device-locks")
|
||||
for path in lock_dir.glob("device-*.lock"):
|
||||
owner = cls._read_owner(path)
|
||||
if owner is not None and cls._owner_is_alive(owner):
|
||||
continue
|
||||
if owner is None:
|
||||
try:
|
||||
if time.time() - path.stat().st_mtime < cls._MALFORMED_LOCK_GRACE_SECONDS:
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
removed += 1
|
||||
except OSError:
|
||||
pass
|
||||
return removed
|
||||
+12
-1
@@ -53,6 +53,7 @@ from artemis.controllers.controller_factory import get_controller
|
||||
from artemis.controllers.platform_specific_commands_controller import (
|
||||
get_first_device,
|
||||
)
|
||||
from artemis.runtime import DeviceExecutionLock
|
||||
from artemis.data_engine.engine import DataEngine
|
||||
from artemis.data_engine.trace import DataEngineCallbackHandler
|
||||
from artemis.graph.graph import get_graph
|
||||
@@ -469,7 +470,13 @@ class Agent:
|
||||
last_state: State | None = None
|
||||
last_state_snapshot: dict | None = None
|
||||
output = None
|
||||
device_lock = DeviceExecutionLock(
|
||||
self._device_context.device_id,
|
||||
description=f"automation task: {request.goal[:120]}",
|
||||
)
|
||||
try:
|
||||
if os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
|
||||
await asyncio.to_thread(device_lock.acquire)
|
||||
async with context:
|
||||
recording_started = False
|
||||
if self._config.video_recording_tools_enabled:
|
||||
@@ -634,7 +641,11 @@ class Agent:
|
||||
|
||||
raise
|
||||
finally:
|
||||
await self._finalize_tracing(task=task, context=context)
|
||||
try:
|
||||
await self._finalize_tracing(task=task, context=context)
|
||||
finally:
|
||||
if os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
|
||||
await asyncio.to_thread(device_lock.release)
|
||||
|
||||
async with self._task_lock:
|
||||
if self._current_task and not self._current_task.done():
|
||||
|
||||
+24
-1
@@ -25,6 +25,7 @@ import functools
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import random
|
||||
import time
|
||||
from typing import Any, Literal, TypeVar, overload
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -67,7 +68,29 @@ def _handle_llm_pause_and_resume(last_error: Exception) -> Path:
|
||||
pass
|
||||
|
||||
if _CURRENT_DATA_ENGINE:
|
||||
_CURRENT_DATA_ENGINE._publish("task_paused", {"error": err_msg})
|
||||
step_id = getattr(_CURRENT_DATA_ENGINE, "current_step_id", None)
|
||||
timestamp = time.time()
|
||||
try:
|
||||
# Persist exhausted LLM retries as a failed call so the same error
|
||||
# card is available both in the live stream and after a refresh.
|
||||
_CURRENT_DATA_ENGINE.record_trace(
|
||||
type="llm_call",
|
||||
name="llm_pause",
|
||||
payload={"error": err_msg, "pause": True},
|
||||
step_id=step_id,
|
||||
status="failed",
|
||||
)
|
||||
except Exception as trace_error:
|
||||
llm_logger.warning("Failed to persist paused LLM error trace: %s", trace_error)
|
||||
|
||||
_CURRENT_DATA_ENGINE._publish(
|
||||
"task_paused",
|
||||
{
|
||||
"error": err_msg,
|
||||
"step_id": str(step_id) if step_id else None,
|
||||
"timestamp": timestamp,
|
||||
},
|
||||
)
|
||||
|
||||
return pause_file
|
||||
|
||||
|
||||
Reference in new issue
Block a user