Stabilize task execution and UI lifecycle
This commit is contained in:
1 parent
c78c201e94
commit
4927c4f0b9
35 files changed
+6611
-170
No files matched your search
@@ -28,7 +28,9 @@ class ServerState:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.ipc_subscribers: list[Callable[[str, Any], None]] = []
|
self.ipc_subscribers: list[Callable[[str, Any], None]] = []
|
||||||
self.ipc_server: asyncio.Server | None = None
|
self.ipc_server: asyncio.Server | None = None
|
||||||
|
self.ipc_serve_task: asyncio.Task | None = None
|
||||||
self.ipc_port: int | None = None
|
self.ipc_port: int | None = None
|
||||||
|
self.is_shutting_down: bool = False
|
||||||
|
|
||||||
self.current_process: asyncio.subprocess.Process | None = None
|
self.current_process: asyncio.subprocess.Process | None = None
|
||||||
self.current_goal: str | None = None
|
self.current_goal: str | None = None
|
||||||
@@ -41,6 +43,7 @@ class ServerState:
|
|||||||
# Unified single source of truth for task queue
|
# Unified single source of truth for task queue
|
||||||
self.queue_items: list[dict[str, Any]] = []
|
self.queue_items: list[dict[str, Any]] = []
|
||||||
self._wake_event: asyncio.Event | None = None
|
self._wake_event: asyncio.Event | None = None
|
||||||
|
self._shutdown_event: asyncio.Event | None = None
|
||||||
self.worker_task: asyncio.Task | None = None
|
self.worker_task: asyncio.Task | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -58,6 +61,22 @@ class ServerState:
|
|||||||
self._wake_event = asyncio.Event()
|
self._wake_event = asyncio.Event()
|
||||||
return self._wake_event
|
return self._wake_event
|
||||||
|
|
||||||
|
@property
|
||||||
|
def shutdown_event(self) -> asyncio.Event:
|
||||||
|
"""Event set as soon as the HTTP server receives a shutdown signal."""
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
except RuntimeError:
|
||||||
|
loop = None
|
||||||
|
|
||||||
|
if self._shutdown_event is None or (
|
||||||
|
loop
|
||||||
|
and getattr(self._shutdown_event, "_loop", None) is not None
|
||||||
|
and self._shutdown_event._loop != loop
|
||||||
|
):
|
||||||
|
self._shutdown_event = asyncio.Event()
|
||||||
|
return self._shutdown_event
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def task_queue(self) -> list[dict[str, Any]]:
|
def task_queue(self) -> list[dict[str, Any]]:
|
||||||
"""Backward compatibility alias for queue_items."""
|
"""Backward compatibility alias for queue_items."""
|
||||||
@@ -131,6 +150,19 @@ class ServerState:
|
|||||||
def is_paused(self) -> bool:
|
def is_paused(self) -> bool:
|
||||||
return PAUSE_FILE.exists()
|
return PAUSE_FILE.exists()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def paused_error(self) -> str | None:
|
||||||
|
"""Return the persisted pause reason for clients that missed the SSE event."""
|
||||||
|
if not PAUSE_FILE.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
message = PAUSE_FILE.read_text(encoding="utf-8", errors="replace").strip()
|
||||||
|
except OSError:
|
||||||
|
return "AI model request failed. The task is paused."
|
||||||
|
if message.startswith("LLM Error: "):
|
||||||
|
message = message[len("LLM Error: ") :]
|
||||||
|
return message or "AI model request failed. The task is paused."
|
||||||
|
|
||||||
def add_subscriber(self, callback: Callable[[str, Any], None]):
|
def add_subscriber(self, callback: Callable[[str, Any], None]):
|
||||||
if callback not in self.ipc_subscribers:
|
if callback not in self.ipc_subscribers:
|
||||||
self.ipc_subscribers.append(callback)
|
self.ipc_subscribers.append(callback)
|
||||||
|
|||||||
@@ -206,6 +206,48 @@ class StepRepository:
|
|||||||
|
|
||||||
return result_payload
|
return result_payload
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_legacy_pause_error(payload_raw: Any) -> str | None:
|
||||||
|
"""Extract an LLM error from the warning trace used by older runners."""
|
||||||
|
payload_obj = payload_raw
|
||||||
|
if isinstance(payload_raw, str):
|
||||||
|
try:
|
||||||
|
payload_obj = json.loads(payload_raw)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if not isinstance(payload_obj, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
message = payload_obj.get("message")
|
||||||
|
if not isinstance(message, str) or "LLM Error:" not in message:
|
||||||
|
return None
|
||||||
|
pause_marker = ". Pausing execution"
|
||||||
|
if pause_marker not in message:
|
||||||
|
return None
|
||||||
|
|
||||||
|
error = message.split("LLM Error:", 1)[1].split(pause_marker, 1)[0].strip()
|
||||||
|
return error or None
|
||||||
|
|
||||||
|
def _normalize_display_trace(self, trace_dict: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Normalize traces consumed by the task stream's tool/error cards."""
|
||||||
|
if trace_dict.get("type") == "log":
|
||||||
|
error = self._extract_legacy_pause_error(trace_dict.get("payload"))
|
||||||
|
if error:
|
||||||
|
trace_dict.update(
|
||||||
|
{
|
||||||
|
"type": "llm_call",
|
||||||
|
"name": "llm_pause",
|
||||||
|
"status": "failed",
|
||||||
|
"payload": {"error": error, "pause": True},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return trace_dict
|
||||||
|
|
||||||
|
trace_dict["payload"] = self._clean_tool_payload(
|
||||||
|
trace_dict.get("payload"), trace_dict.get("type")
|
||||||
|
)
|
||||||
|
return trace_dict
|
||||||
|
|
||||||
def get_session_steps(self, session_id: str, client: str | None = None) -> list[dict[str, Any]]:
|
def get_session_steps(self, session_id: str, client: str | None = None) -> list[dict[str, Any]]:
|
||||||
with db_session(self.db_path) as conn:
|
with db_session(self.db_path) as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
@@ -249,17 +291,18 @@ class StepRepository:
|
|||||||
" t1.payload, t2.name as agent_name FROM traces t1 LEFT"
|
" t1.payload, t2.name as agent_name FROM traces t1 LEFT"
|
||||||
" JOIN traces t2 ON t1.parent_trace_id = t2.trace_id WHERE"
|
" JOIN traces t2 ON t1.parent_trace_id = t2.trace_id WHERE"
|
||||||
" t1.step_id = ? AND (t1.type = 'tool' OR t1.type = 'action' OR (t1.type ="
|
" t1.step_id = ? AND (t1.type = 'tool' OR t1.type = 'action' OR (t1.type ="
|
||||||
" 'llm_call' AND t1.status = 'failed')) ORDER BY"
|
" 'llm_call' AND t1.status = 'failed') OR (t1.type = 'log' AND"
|
||||||
|
" t1.payload LIKE '%LLM Error:%Pausing execution%' AND NOT EXISTS"
|
||||||
|
" (SELECT 1 FROM traces t3 WHERE t3.session_id = t1.session_id AND"
|
||||||
|
" t3.type = 'llm_call' AND t3.status = 'failed' AND"
|
||||||
|
" ABS(t3.timestamp - t1.timestamp) < 2))) ORDER BY"
|
||||||
" t1.timestamp ASC",
|
" t1.timestamp ASC",
|
||||||
(step_id,),
|
(step_id,),
|
||||||
)
|
)
|
||||||
trace_rows = cursor.fetchall()
|
trace_rows = cursor.fetchall()
|
||||||
generic_tools = []
|
generic_tools = []
|
||||||
for tr in trace_rows:
|
for tr in trace_rows:
|
||||||
trace_dict = dict(tr)
|
trace_dict = self._normalize_display_trace(dict(tr))
|
||||||
trace_dict["payload"] = self._clean_tool_payload(
|
|
||||||
trace_dict.get("payload"), trace_dict.get("type")
|
|
||||||
)
|
|
||||||
generic_tools.append(trace_dict)
|
generic_tools.append(trace_dict)
|
||||||
step_dict["generic_tools"] = generic_tools
|
step_dict["generic_tools"] = generic_tools
|
||||||
|
|
||||||
@@ -320,7 +363,11 @@ class StepRepository:
|
|||||||
" t2.name as agent_name FROM traces t1 LEFT JOIN traces t2 ON"
|
" t2.name as agent_name FROM traces t1 LEFT JOIN traces t2 ON"
|
||||||
" t1.parent_trace_id = t2.trace_id WHERE t1.session_id = ? AND"
|
" t1.parent_trace_id = t2.trace_id WHERE t1.session_id = ? AND"
|
||||||
" (t1.step_id IS NULL OR t1.step_id = '') AND (t1.type = 'tool' OR"
|
" (t1.step_id IS NULL OR t1.step_id = '') AND (t1.type = 'tool' OR"
|
||||||
" (t1.type = 'llm_call' AND t1.status = 'failed')) ORDER BY"
|
" (t1.type = 'llm_call' AND t1.status = 'failed') OR (t1.type = 'log'"
|
||||||
|
" AND t1.payload LIKE '%LLM Error:%Pausing execution%' AND NOT EXISTS"
|
||||||
|
" (SELECT 1 FROM traces t3 WHERE t3.session_id = t1.session_id AND"
|
||||||
|
" t3.type = 'llm_call' AND t3.status = 'failed' AND"
|
||||||
|
" ABS(t3.timestamp - t1.timestamp) < 2))) ORDER BY"
|
||||||
" t1.timestamp ASC",
|
" t1.timestamp ASC",
|
||||||
(session_id,),
|
(session_id,),
|
||||||
)
|
)
|
||||||
@@ -330,10 +377,7 @@ class StepRepository:
|
|||||||
sorted_steps = sorted(steps, key=lambda s: s.get("timestamp") or 0)
|
sorted_steps = sorted(steps, key=lambda s: s.get("timestamp") or 0)
|
||||||
|
|
||||||
for tr in stepless_rows:
|
for tr in stepless_rows:
|
||||||
trace_dict = dict(tr)
|
trace_dict = self._normalize_display_trace(dict(tr))
|
||||||
trace_dict["payload"] = self._clean_tool_payload(
|
|
||||||
trace_dict.get("payload"), trace_dict.get("type")
|
|
||||||
)
|
|
||||||
|
|
||||||
tr_time = trace_dict.get("timestamp") or 0
|
tr_time = trace_dict.get("timestamp") or 0
|
||||||
target_step = None
|
target_step = None
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from contextlib import suppress
|
||||||
import json
|
import json
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
@@ -148,6 +149,7 @@ async def get_status():
|
|||||||
is_paused = state.is_paused
|
is_paused = state.is_paused
|
||||||
return {
|
return {
|
||||||
"status": "paused" if is_paused else "running",
|
"status": "paused" if is_paused else "running",
|
||||||
|
"paused_error": state.paused_error if is_paused else None,
|
||||||
"goal": running_goal,
|
"goal": running_goal,
|
||||||
"pid": state.current_process.pid if state.current_process else None,
|
"pid": state.current_process.pid if state.current_process else None,
|
||||||
"session_id": running_sid,
|
"session_id": running_sid,
|
||||||
@@ -163,6 +165,7 @@ async def get_status():
|
|||||||
conn_model_info = model_service.get_active_model_info(conn_profile)
|
conn_model_info = model_service.get_active_model_info(conn_profile)
|
||||||
return {
|
return {
|
||||||
"status": "paused" if is_paused else "running",
|
"status": "paused" if is_paused else "running",
|
||||||
|
"paused_error": state.paused_error if is_paused else None,
|
||||||
"goal": conn_info.get("goal"),
|
"goal": conn_info.get("goal"),
|
||||||
"pid": conn_info.get("pid"),
|
"pid": conn_info.get("pid"),
|
||||||
"session_id": latest_session_id,
|
"session_id": latest_session_id,
|
||||||
@@ -184,6 +187,7 @@ async def get_status():
|
|||||||
async def stream_events(session_id: str, client: str | None = None):
|
async def stream_events(session_id: str, client: str | None = None):
|
||||||
async def event_generator():
|
async def event_generator():
|
||||||
queue = asyncio.Queue()
|
queue = asyncio.Queue()
|
||||||
|
event_loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
def callback(event_type, data):
|
def callback(event_type, data):
|
||||||
try:
|
try:
|
||||||
@@ -210,22 +214,57 @@ async def stream_events(session_id: str, client: str | None = None):
|
|||||||
return
|
return
|
||||||
|
|
||||||
sanitized_data = ipc_service.sanitize_event_data(event_type, data)
|
sanitized_data = ipc_service.sanitize_event_data(event_type, data)
|
||||||
loop = asyncio.get_running_loop()
|
event_loop.call_soon_threadsafe(queue.put_nowait, (event_type, sanitized_data))
|
||||||
loop.call_soon_threadsafe(queue.put_nowait, (event_type, sanitized_data))
|
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
state.add_subscriber(callback)
|
state.add_subscriber(callback)
|
||||||
yield f'event: info\ndata: {{"message": "Subscribed to session {session_id}"}}\n\n'
|
yield f'event: info\ndata: {{"message": "Subscribed to session {session_id}"}}\n\n'
|
||||||
|
|
||||||
|
shutdown_waiter = asyncio.create_task(state.shutdown_event.wait())
|
||||||
|
queue_waiter = None
|
||||||
try:
|
try:
|
||||||
while True:
|
while not state.is_shutting_down:
|
||||||
try:
|
queue_waiter = asyncio.create_task(queue.get())
|
||||||
event_type, data = await asyncio.wait_for(queue.get(), timeout=5.0)
|
done, _ = await asyncio.wait(
|
||||||
|
{queue_waiter, shutdown_waiter},
|
||||||
|
timeout=5.0,
|
||||||
|
return_when=asyncio.FIRST_COMPLETED,
|
||||||
|
)
|
||||||
|
if shutdown_waiter in done:
|
||||||
|
queue_waiter.cancel()
|
||||||
|
with suppress(asyncio.CancelledError):
|
||||||
|
await queue_waiter
|
||||||
|
queue_waiter = None
|
||||||
|
break
|
||||||
|
if queue_waiter in done:
|
||||||
|
event_type, data = queue_waiter.result()
|
||||||
|
queue_waiter = None
|
||||||
yield f"event: {event_type}\ndata: {json.dumps(data, default=str)}\n\n"
|
yield f"event: {event_type}\ndata: {json.dumps(data, default=str)}\n\n"
|
||||||
except TimeoutError:
|
else:
|
||||||
|
queue_waiter.cancel()
|
||||||
|
with suppress(asyncio.CancelledError):
|
||||||
|
await queue_waiter
|
||||||
|
queue_waiter = None
|
||||||
yield "event: keep-alive\ndata: {}\n\n"
|
yield "event: keep-alive\ndata: {}\n\n"
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
waiters = (queue_waiter, shutdown_waiter)
|
||||||
|
for waiter in waiters:
|
||||||
|
if waiter is not None and not waiter.done():
|
||||||
|
waiter.cancel()
|
||||||
|
for waiter in waiters:
|
||||||
|
if waiter is not None:
|
||||||
|
with suppress(asyncio.CancelledError):
|
||||||
|
await waiter
|
||||||
state.remove_subscriber(callback)
|
state.remove_subscriber(callback)
|
||||||
|
|
||||||
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
return StreamingResponse(
|
||||||
|
event_generator(),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache, no-transform",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -20,7 +20,9 @@ Modular entrypoint for full trace inspection, step replay, and task execution ma
|
|||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import signal
|
||||||
import sys
|
import sys
|
||||||
|
from types import FrameType
|
||||||
|
|
||||||
# Bootstrap sys.path to allow running from any CWD
|
# Bootstrap sys.path to allow running from any CWD
|
||||||
_current_p = Path(__file__).resolve().parent
|
_current_p = Path(__file__).resolve().parent
|
||||||
@@ -43,6 +45,9 @@ for _p in (str(_workspace_root), str(_apps_dir), str(_admin_console_dir), str(_c
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import FileResponse, HTMLResponse
|
from fastapi.responses import FileResponse, HTMLResponse
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
from artemis.runtime import DeviceExecutionLock
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from admin_console.core.config import (
|
from admin_console.core.config import (
|
||||||
@@ -97,6 +102,11 @@ app.add_middleware(
|
|||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def on_startup():
|
async def on_startup():
|
||||||
"""Startup lifecycle hooks."""
|
"""Startup lifecycle hooks."""
|
||||||
|
state.is_shutting_down = False
|
||||||
|
state.shutdown_event.clear()
|
||||||
|
cleaned_device_locks = DeviceExecutionLock.cleanup_stale_locks()
|
||||||
|
if cleaned_device_locks:
|
||||||
|
print(f"[ServerStartup] Removed {cleaned_device_locks} stale device lock(s).")
|
||||||
asyncio.create_task(asyncio.to_thread(task_queue_service.archive_older_replays_on_launch))
|
asyncio.create_task(asyncio.to_thread(task_queue_service.archive_older_replays_on_launch))
|
||||||
asyncio.create_task(
|
asyncio.create_task(
|
||||||
asyncio.to_thread(task_queue_service.verify_chunks_exist_on_launch, replay_manager)
|
asyncio.to_thread(task_queue_service.verify_chunks_exist_on_launch, replay_manager)
|
||||||
@@ -110,6 +120,39 @@ async def on_startup():
|
|||||||
state.worker_task = asyncio.create_task(task_queue_service.queue_worker())
|
state.worker_task = asyncio.create_task(task_queue_service.queue_worker())
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("shutdown")
|
||||||
|
async def on_shutdown():
|
||||||
|
"""Stop task and IPC children before the UI server exits."""
|
||||||
|
state.is_shutting_down = True
|
||||||
|
state.shutdown_event.set()
|
||||||
|
task_queue_service._broadcast_event("server_shutdown", {"status": "stopping"})
|
||||||
|
owned_session_ids = {
|
||||||
|
str(item["session_id"])
|
||||||
|
for item in state.queue_items
|
||||||
|
if isinstance(item, dict) and item.get("status") == "running" and item.get("session_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
worker = state.worker_task
|
||||||
|
if worker is not None and not worker.done():
|
||||||
|
worker.cancel()
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(worker, timeout=5.0)
|
||||||
|
except (asyncio.CancelledError, TimeoutError):
|
||||||
|
pass
|
||||||
|
state.worker_task = None
|
||||||
|
|
||||||
|
if state.current_process is not None and state.current_process.returncode is None:
|
||||||
|
await task_queue_service._terminate_worker_process(state.current_process)
|
||||||
|
DeviceExecutionLock.cleanup_stale_locks()
|
||||||
|
state.current_process = None
|
||||||
|
state.queue_items.clear()
|
||||||
|
for session_id in owned_session_ids:
|
||||||
|
session_repo.update_session_status(session_id, "cancelled")
|
||||||
|
|
||||||
|
await ipc_service.stop_server()
|
||||||
|
state.ipc_subscribers.clear()
|
||||||
|
|
||||||
|
|
||||||
# Mount modular routers
|
# Mount modular routers
|
||||||
app.include_router(stream.router)
|
app.include_router(stream.router)
|
||||||
app.include_router(media.router)
|
app.include_router(media.router)
|
||||||
@@ -278,11 +321,80 @@ task_queue = state.task_queue
|
|||||||
queue_goals = state.queue_goals
|
queue_goals = state.queue_goals
|
||||||
|
|
||||||
|
|
||||||
def main():
|
class ArtemisUvicornServer(uvicorn.Server):
|
||||||
import uvicorn
|
"""Notify application streams before Uvicorn waits for them to close."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _task_is_active() -> bool:
|
||||||
|
"""Check task state without doing process I/O from a signal handler."""
|
||||||
|
proc = state.current_process
|
||||||
|
if proc is not None and proc.returncode is None:
|
||||||
|
return True
|
||||||
|
return any(
|
||||||
|
isinstance(item, dict) and item.get("status") == "running" for item in state.queue_items
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _schedule_signal_report(message: str) -> None:
|
||||||
|
"""Defer console output so a signal cannot re-enter Colorama writes."""
|
||||||
|
try:
|
||||||
|
asyncio.get_running_loop().call_soon(print, message)
|
||||||
|
except RuntimeError:
|
||||||
|
try:
|
||||||
|
os.write(2, f"{message}\n".encode(errors="replace"))
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def handle_exit(self, sig: int, frame: FrameType | None) -> None:
|
||||||
|
try:
|
||||||
|
signal_name = signal.Signals(sig).name
|
||||||
|
except ValueError:
|
||||||
|
signal_name = "UNKNOWN"
|
||||||
|
frame_location = "unknown"
|
||||||
|
if frame is not None:
|
||||||
|
frame_location = f"{frame.f_code.co_filename}:{frame.f_lineno}"
|
||||||
|
signal_report = (
|
||||||
|
f"[ServerSignal] Received {signal_name} ({sig}); "
|
||||||
|
f"server PID={os.getpid()}, parent PID={os.getppid()}, frame={frame_location}"
|
||||||
|
)
|
||||||
|
self._schedule_signal_report(signal_report)
|
||||||
|
|
||||||
|
if sys.platform == "win32" and sig == signal.SIGINT and self._task_is_active():
|
||||||
|
self._schedule_signal_report(
|
||||||
|
"[ServerSignal] Ignored Windows SIGINT while a mobile task is active. "
|
||||||
|
"Stop the task first, or press Ctrl+Break to force server shutdown."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
state.is_shutting_down = True
|
||||||
|
state.shutdown_event.set()
|
||||||
|
super().handle_exit(sig, frame)
|
||||||
|
|
||||||
|
|
||||||
|
def run_ui_server(host: str, port: int, reload: bool = False) -> None:
|
||||||
|
"""Run the UI server with bounded, signal-aware graceful shutdown."""
|
||||||
|
if reload:
|
||||||
|
uvicorn.run(
|
||||||
|
app,
|
||||||
|
host=host,
|
||||||
|
port=port,
|
||||||
|
reload=True,
|
||||||
|
timeout_graceful_shutdown=5,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
config = uvicorn.Config(
|
||||||
|
app,
|
||||||
|
host=host,
|
||||||
|
port=port,
|
||||||
|
timeout_graceful_shutdown=5,
|
||||||
|
)
|
||||||
|
ArtemisUvicornServer(config).run()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
port = int(os.environ.get("ANTIGRAVITY_SIDECAR_WEB_PORT", 8000))
|
port = int(os.environ.get("ANTIGRAVITY_SIDECAR_WEB_PORT", 8000))
|
||||||
uvicorn.run(app, host="0.0.0.0", port=port)
|
run_ui_server(host="0.0.0.0", port=port)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from contextlib import suppress
|
||||||
import json
|
import json
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -134,7 +135,8 @@ class IPCService:
|
|||||||
async def start_server(cls):
|
async def start_server(cls):
|
||||||
async def handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
|
async def handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
|
||||||
current_session_id = None
|
current_session_id = None
|
||||||
while True:
|
try:
|
||||||
|
while not state.is_shutting_down:
|
||||||
line = await reader.readline()
|
line = await reader.readline()
|
||||||
if not line:
|
if not line:
|
||||||
break
|
break
|
||||||
@@ -186,21 +188,24 @@ class IPCService:
|
|||||||
cb(event_type, data)
|
cb(event_type, data)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
except (ValueError, UnicodeDecodeError, TypeError) as e:
|
||||||
print(f"IPC parse error: {e}")
|
print(f"IPC parse error: {e}")
|
||||||
|
except asyncio.CancelledError:
|
||||||
if current_session_id and str(current_session_id) in state.active_connections:
|
raise
|
||||||
try:
|
except (ConnectionResetError, BrokenPipeError, OSError):
|
||||||
del state.active_connections[str(current_session_id)]
|
# Normal when a task or the UI server is shutting down on Windows.
|
||||||
except KeyError:
|
|
||||||
pass
|
pass
|
||||||
|
finally:
|
||||||
|
if current_session_id and str(current_session_id) in state.active_connections:
|
||||||
|
state.active_connections.pop(str(current_session_id), None)
|
||||||
writer.close()
|
writer.close()
|
||||||
|
with suppress(ConnectionResetError, BrokenPipeError, OSError):
|
||||||
await writer.wait_closed()
|
await writer.wait_closed()
|
||||||
|
|
||||||
server = await asyncio.start_server(handle_client, "127.0.0.1", 0, limit=1024 * 1024 * 100)
|
server = await asyncio.start_server(handle_client, "127.0.0.1", 0, limit=1024 * 1024 * 100)
|
||||||
state.ipc_port = server.sockets[0].getsockname()[1]
|
state.ipc_port = server.sockets[0].getsockname()[1]
|
||||||
state.ipc_server = server
|
state.ipc_server = server
|
||||||
asyncio.create_task(server.serve_forever())
|
state.ipc_serve_task = asyncio.create_task(server.serve_forever())
|
||||||
print(f"Internal IPC server started on port {state.ipc_port}")
|
print(f"Internal IPC server started on port {state.ipc_port}")
|
||||||
try:
|
try:
|
||||||
from artemis.config import write_ipc_port
|
from artemis.config import write_ipc_port
|
||||||
@@ -209,5 +214,34 @@ class IPCService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Failed to write IPC port file: {e}")
|
print(f"Failed to write IPC port file: {e}")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def stop_server(cls):
|
||||||
|
"""Close IPC listeners and task connections during application shutdown."""
|
||||||
|
for connection in list(state.active_connections.values()):
|
||||||
|
writer = connection.get("writer")
|
||||||
|
if writer is not None:
|
||||||
|
writer.close()
|
||||||
|
with suppress(ConnectionResetError, BrokenPipeError, OSError):
|
||||||
|
await writer.wait_closed()
|
||||||
|
state.active_connections.clear()
|
||||||
|
|
||||||
|
if state.ipc_server is not None:
|
||||||
|
state.ipc_server.close()
|
||||||
|
await state.ipc_server.wait_closed()
|
||||||
|
state.ipc_server = None
|
||||||
|
|
||||||
|
if state.ipc_serve_task is not None:
|
||||||
|
state.ipc_serve_task.cancel()
|
||||||
|
with suppress(asyncio.CancelledError):
|
||||||
|
await state.ipc_serve_task
|
||||||
|
state.ipc_serve_task = None
|
||||||
|
state.ipc_port = None
|
||||||
|
try:
|
||||||
|
from artemis.config import clear_ipc_port
|
||||||
|
|
||||||
|
clear_ipc_port()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to clear IPC port state: {e}")
|
||||||
|
|
||||||
|
|
||||||
ipc_service = IPCService()
|
ipc_service = IPCService()
|
||||||
@@ -16,6 +16,7 @@ import asyncio
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -76,6 +77,31 @@ class TaskQueueService:
|
|||||||
if not (isinstance(t, dict) and t.get("session_id") == session_id)
|
if not (isinstance(t, dict) and t.get("session_id") == session_id)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _subprocess_creation_kwargs() -> dict[str, int]:
|
||||||
|
"""Keep task workers out of the UI server's Windows console group.
|
||||||
|
|
||||||
|
Without a new process group, a console control event intended for the
|
||||||
|
uvicorn server is also delivered to a task that is still importing
|
||||||
|
Artemis, causing STATUS_CONTROL_C_EXIT (0xC000013A).
|
||||||
|
"""
|
||||||
|
if sys.platform == "win32":
|
||||||
|
return {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _terminate_worker_process(proc: asyncio.subprocess.Process | None) -> None:
|
||||||
|
"""Stop a worker without changing the established POSIX behavior."""
|
||||||
|
if proc is None or proc.returncode is not None:
|
||||||
|
return
|
||||||
|
if sys.platform == "win32":
|
||||||
|
await process_supervisor.stop_process(proc)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
proc.kill()
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def ensure_worker_running(cls):
|
def ensure_worker_running(cls):
|
||||||
"""Guarantees that the background queue worker task is active and running."""
|
"""Guarantees that the background queue worker task is active and running."""
|
||||||
@@ -182,6 +208,7 @@ class TaskQueueService:
|
|||||||
*cmd,
|
*cmd,
|
||||||
cwd=str(WORKSPACE_ROOT),
|
cwd=str(WORKSPACE_ROOT),
|
||||||
env=env,
|
env=env,
|
||||||
|
**cls._subprocess_creation_kwargs(),
|
||||||
)
|
)
|
||||||
state.current_process = proc
|
state.current_process = proc
|
||||||
task_item["pid"] = proc.pid
|
task_item["pid"] = proc.pid
|
||||||
@@ -193,10 +220,7 @@ class TaskQueueService:
|
|||||||
print(
|
print(
|
||||||
f"[QueueWorker] Task [{sess_id}] was cancelled during launch. Terminating."
|
f"[QueueWorker] Task [{sess_id}] was cancelled during launch. Terminating."
|
||||||
)
|
)
|
||||||
try:
|
await cls._terminate_worker_process(proc)
|
||||||
proc.kill()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 3. Await subprocess completion
|
# 3. Await subprocess completion
|
||||||
returncode = await proc.wait()
|
returncode = await proc.wait()
|
||||||
@@ -225,10 +249,7 @@ class TaskQueueService:
|
|||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
print("[QueueWorker] Task received cancellation signal.")
|
print("[QueueWorker] Task received cancellation signal.")
|
||||||
if state.current_process and state.current_process.returncode is None:
|
if state.current_process and state.current_process.returncode is None:
|
||||||
try:
|
await cls._terminate_worker_process(state.current_process)
|
||||||
state.current_process.kill()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[QueueWorker] Unexpected error executing task: {e}")
|
print(f"[QueueWorker] Unexpected error executing task: {e}")
|
||||||
|
|||||||
@@ -70,7 +70,7 @@
|
|||||||
<div class="item-header-actions">
|
<div class="item-header-actions">
|
||||||
<span class="item-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
|
<span class="item-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
|
||||||
<button
|
<button
|
||||||
*ngIf="getTaskStatus(session) !== 'running'"
|
*ngIf="getTaskStatus(session) !== 'running' && getTaskStatus(session) !== 'paused'"
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-delete-card"
|
class="btn-delete-card"
|
||||||
(click)="deleteTask(session.session_id, $event)"
|
(click)="deleteTask(session.session_id, $event)"
|
||||||
@@ -115,7 +115,7 @@
|
|||||||
<div class="item-header-actions">
|
<div class="item-header-actions">
|
||||||
<span class="item-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
|
<span class="item-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
|
||||||
<button
|
<button
|
||||||
*ngIf="getTaskStatus(session) !== 'running'"
|
*ngIf="getTaskStatus(session) !== 'running' && getTaskStatus(session) !== 'paused'"
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-delete-card"
|
class="btn-delete-card"
|
||||||
(click)="deleteTask(session.session_id, $event)"
|
(click)="deleteTask(session.session_id, $event)"
|
||||||
@@ -285,7 +285,6 @@
|
|||||||
|
|
||||||
<!-- Event Logs Section -->
|
<!-- Event Logs Section -->
|
||||||
<main class="stream-logs-content" (scroll)="onStreamScroll($event)">
|
<main class="stream-logs-content" (scroll)="onStreamScroll($event)">
|
||||||
|
|
||||||
<div *ngIf="consolidatedBlocks().length === 0 && !showPlanningLoader()" class="empty-state">
|
<div *ngIf="consolidatedBlocks().length === 0 && !showPlanningLoader()" class="empty-state">
|
||||||
<div class="empty-icon"><span class="material-symbols-outlined">sensors</span></div>
|
<div class="empty-icon"><span class="material-symbols-outlined">sensors</span></div>
|
||||||
<h3>No Stream Activity Selected</h3>
|
<h3>No Stream Activity Selected</h3>
|
||||||
@@ -516,13 +515,30 @@
|
|||||||
<ng-container *ngIf="item.type === 'tool'">
|
<ng-container *ngIf="item.type === 'tool'">
|
||||||
<!-- Failed LLM Calls (Permanently maintain Action Card format in red) -->
|
<!-- Failed LLM Calls (Permanently maintain Action Card format in red) -->
|
||||||
<div class="step-android-action-card tool-action-card is-failed" *ngIf="item.data.type === 'llm_call' && item.data.status === 'failed'">
|
<div class="step-android-action-card tool-action-card is-failed" *ngIf="item.data.type === 'llm_call' && item.data.status === 'failed'">
|
||||||
<div class="action-card-header">
|
<div class="action-card-header llm-failure-header">
|
||||||
<span class="material-symbols-outlined action-icon is-failed">error</span>
|
<span class="material-symbols-outlined action-icon is-failed">error</span>
|
||||||
<span class="action-title">LLM Request Failed</span>
|
<span class="action-title">LLM Request Failed</span>
|
||||||
|
<span class="action-status-badge is-paused" *ngIf="isTerminalLLMFailure(item.data); else failedLlmStatus">
|
||||||
|
<span class="material-symbols-outlined status-icon">pause_circle</span>
|
||||||
|
Paused
|
||||||
|
</span>
|
||||||
|
<ng-template #failedLlmStatus>
|
||||||
<span class="action-status-badge is-failed">
|
<span class="action-status-badge is-failed">
|
||||||
<span class="material-symbols-outlined status-icon">warning</span>
|
<span class="material-symbols-outlined status-icon">warning</span>
|
||||||
Failed
|
Failed
|
||||||
</span>
|
</span>
|
||||||
|
</ng-template>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-retry-llm"
|
||||||
|
*ngIf="isTerminalLLMFailure(item.data)"
|
||||||
|
(click)="retryPausedTask($event)"
|
||||||
|
title="Retry the paused AI request"
|
||||||
|
aria-label="Retry the paused AI request"
|
||||||
|
>
|
||||||
|
<span class="material-symbols-outlined">refresh</span>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="action-card-body">
|
<div class="action-card-body">
|
||||||
<div class="action-detail-row">
|
<div class="action-detail-row">
|
||||||
|
|||||||
@@ -610,6 +610,12 @@ $uniform-gap: 8px;
|
|||||||
animation: pulse-black-dot-small 1.5s infinite ease-in-out;
|
animation: pulse-black-dot-small 1.5s infinite ease-in-out;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
&.paused {
|
||||||
|
background: #fff7ed;
|
||||||
|
color: #c2410c;
|
||||||
|
border-color: #fdba74;
|
||||||
|
&::before { background-color: #f97316; }
|
||||||
|
}
|
||||||
&.completed {
|
&.completed {
|
||||||
background: #fafafa;
|
background: #fafafa;
|
||||||
color: #1f1f1f;
|
color: #1f1f1f;
|
||||||
@@ -2336,6 +2342,61 @@ $uniform-gap: 8px;
|
|||||||
color: #ef4444;
|
color: #ef4444;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.is-paused {
|
||||||
|
background: #fff7ed;
|
||||||
|
color: #c2410c;
|
||||||
|
border: 1px solid #fed7aa;
|
||||||
|
|
||||||
|
.status-icon {
|
||||||
|
color: #f97316;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.llm-failure-header {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
.btn-retry-llm {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-height: 26px;
|
||||||
|
padding: 3px 9px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #2563eb;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||||
|
transition: background 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
|
||||||
|
|
||||||
|
.material-symbols-outlined {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #eff6ff;
|
||||||
|
border-color: #93c5fd;
|
||||||
|
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid rgba(37, 99, 235, 0.35);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-expand-icon {
|
.action-expand-icon {
|
||||||
|
|||||||
@@ -168,13 +168,13 @@ export class AgentStreamComponent implements AfterViewInit {
|
|||||||
public activeQueue = computed(() => {
|
public activeQueue = computed(() => {
|
||||||
const list = this.agentService.sessions().filter((s) => {
|
const list = this.agentService.sessions().filter((s) => {
|
||||||
const status = this.getTaskStatus(s);
|
const status = this.getTaskStatus(s);
|
||||||
return status === 'running' || status === 'pending';
|
return status === 'running' || status === 'paused' || status === 'pending';
|
||||||
});
|
});
|
||||||
return list.sort((a, b) => {
|
return list.sort((a, b) => {
|
||||||
const statusA = this.getTaskStatus(a);
|
const statusA = this.getTaskStatus(a);
|
||||||
const statusB = this.getTaskStatus(b);
|
const statusB = this.getTaskStatus(b);
|
||||||
if (statusA === 'running') return -1;
|
if (statusA === 'running' || statusA === 'paused') return -1;
|
||||||
if (statusB === 'running') return 1;
|
if (statusB === 'running' || statusB === 'paused') return 1;
|
||||||
return a.start_time - b.start_time;
|
return a.start_time - b.start_time;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -244,6 +244,28 @@ export class AgentStreamComponent implements AfterViewInit {
|
|||||||
return groupBlocksToPhases(this.consolidatedBlocks(), currentSession?.start_time || 0);
|
return groupBlocksToPhases(this.consolidatedBlocks(), currentSession?.start_time || 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
private retryablePauseTrace = computed<any | null>(() => {
|
||||||
|
if (!this.agentService.isPaused()) return null;
|
||||||
|
|
||||||
|
const blocks = this.consolidatedBlocks();
|
||||||
|
for (let blockIndex = blocks.length - 1; blockIndex >= 0; blockIndex--) {
|
||||||
|
const tools = blocks[blockIndex]?.data?.generic_tools;
|
||||||
|
if (!Array.isArray(tools)) continue;
|
||||||
|
|
||||||
|
for (let toolIndex = tools.length - 1; toolIndex >= 0; toolIndex--) {
|
||||||
|
const tool = tools[toolIndex];
|
||||||
|
if (
|
||||||
|
tool?.type === 'llm_call'
|
||||||
|
&& tool?.status === 'failed'
|
||||||
|
&& tool?.payload?.pause === true
|
||||||
|
) {
|
||||||
|
return tool;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
public hasVisibleBlocks(phase: any): boolean {
|
public hasVisibleBlocks(phase: any): boolean {
|
||||||
if (!phase || !phase.blocks) return false;
|
if (!phase || !phase.blocks) return false;
|
||||||
return phase.blocks.some((b: any) => this.hasVisibleContent(b));
|
return phase.blocks.some((b: any) => this.hasVisibleContent(b));
|
||||||
@@ -447,13 +469,13 @@ export class AgentStreamComponent implements AfterViewInit {
|
|||||||
this.isNotesDropdownOpen.set(false);
|
this.isNotesDropdownOpen.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public getTaskStatus(session: Session): 'running' | 'completed' | 'pending' | 'failed' | 'cancelled' {
|
public getTaskStatus(session: Session): 'running' | 'paused' | 'completed' | 'pending' | 'failed' | 'cancelled' {
|
||||||
if (session.session_id === this.agentService.runningSessionId() && this.agentService.agentStatus() === 'running') {
|
if (session.session_id === this.agentService.runningSessionId() && (this.agentService.agentStatus() === 'running' || this.agentService.agentStatus() === 'paused')) {
|
||||||
return 'running';
|
return this.agentService.agentStatus() as 'running' | 'paused';
|
||||||
}
|
}
|
||||||
if (session.status) {
|
if (session.status) {
|
||||||
const s = session.status.toLowerCase();
|
const s = session.status.toLowerCase();
|
||||||
if (s === 'running' || s === 'completed' || s === 'pending' || s === 'failed' || s === 'cancelled') {
|
if (s === 'running' || s === 'paused' || s === 'completed' || s === 'pending' || s === 'failed' || s === 'cancelled') {
|
||||||
return s as any;
|
return s as any;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -819,20 +841,12 @@ export class AgentStreamComponent implements AfterViewInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public isTerminalLLMFailure(tool: any): boolean {
|
public isTerminalLLMFailure(tool: any): boolean {
|
||||||
if (!tool) return false;
|
return !!tool && this.retryablePauseTrace() === tool;
|
||||||
for (const block of this.consolidatedBlocks()) {
|
|
||||||
if (block?.data?.generic_tools) {
|
|
||||||
const tools = block.data.generic_tools;
|
|
||||||
const failedLLMs = tools.filter((t: any) => t && t.type === 'llm_call' && t.status === 'failed');
|
|
||||||
if (failedLLMs.length > 0) {
|
|
||||||
const lastFailed = failedLLMs[failedLLMs.length - 1];
|
|
||||||
if (lastFailed && lastFailed.trace_id === tool.trace_id) {
|
|
||||||
return this.agentService.isPaused() || block.id === this.consolidatedBlocks()[this.consolidatedBlocks().length - 1]?.id;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
public retryPausedTask(event: Event): void {
|
||||||
}
|
event.stopPropagation();
|
||||||
return false;
|
this.agentService.resumeTask();
|
||||||
}
|
}
|
||||||
|
|
||||||
public getLLMErrorText(tool: any): string {
|
public getLLMErrorText(tool: any): string {
|
||||||
|
|||||||
@@ -79,7 +79,7 @@
|
|||||||
<div class="task-card-header-actions">
|
<div class="task-card-header-actions">
|
||||||
<span class="task-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
|
<span class="task-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
|
||||||
<button
|
<button
|
||||||
*ngIf="getTaskStatus(session) !== 'running'"
|
*ngIf="getTaskStatus(session) !== 'running' && getTaskStatus(session) !== 'paused'"
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-delete-card"
|
class="btn-delete-card"
|
||||||
(click)="deleteTask(session.session_id, $event)"
|
(click)="deleteTask(session.session_id, $event)"
|
||||||
@@ -128,7 +128,7 @@
|
|||||||
<div class="task-card-header-actions">
|
<div class="task-card-header-actions">
|
||||||
<span class="task-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
|
<span class="task-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
|
||||||
<button
|
<button
|
||||||
*ngIf="getTaskStatus(session) !== 'running'"
|
*ngIf="getTaskStatus(session) !== 'running' && getTaskStatus(session) !== 'paused'"
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-delete-card"
|
class="btn-delete-card"
|
||||||
(click)="deleteTask(session.session_id, $event)"
|
(click)="deleteTask(session.session_id, $event)"
|
||||||
|
|||||||
@@ -358,6 +358,12 @@ $text-muted: #a1a1aa;
|
|||||||
animation: pulse-black-dot-small 1.5s infinite ease-in-out;
|
animation: pulse-black-dot-small 1.5s infinite ease-in-out;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
&.paused {
|
||||||
|
background: #fff7ed;
|
||||||
|
color: #c2410c;
|
||||||
|
border-color: #fdba74;
|
||||||
|
&::before { background-color: #f97316; }
|
||||||
|
}
|
||||||
&.completed {
|
&.completed {
|
||||||
background: #fafafa;
|
background: #fafafa;
|
||||||
color: #1f1f1f;
|
color: #1f1f1f;
|
||||||
|
|||||||
@@ -46,13 +46,13 @@ export class ChatInterfaceComponent {
|
|||||||
public activeQueue = computed(() => {
|
public activeQueue = computed(() => {
|
||||||
const list = this.agentService.sessions().filter((s) => {
|
const list = this.agentService.sessions().filter((s) => {
|
||||||
const status = this.getTaskStatus(s);
|
const status = this.getTaskStatus(s);
|
||||||
return status === 'running' || status === 'pending';
|
return status === 'running' || status === 'paused' || status === 'pending';
|
||||||
});
|
});
|
||||||
return list.sort((a, b) => {
|
return list.sort((a, b) => {
|
||||||
const statusA = this.getTaskStatus(a);
|
const statusA = this.getTaskStatus(a);
|
||||||
const statusB = this.getTaskStatus(b);
|
const statusB = this.getTaskStatus(b);
|
||||||
if (statusA === 'running') return -1;
|
if (statusA === 'running' || statusA === 'paused') return -1;
|
||||||
if (statusB === 'running') return 1;
|
if (statusB === 'running' || statusB === 'paused') return 1;
|
||||||
return a.start_time - b.start_time; // FIFO order
|
return a.start_time - b.start_time; // FIFO order
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -169,13 +169,13 @@ export class ChatInterfaceComponent {
|
|||||||
/**
|
/**
|
||||||
* Determine the current task execution status
|
* Determine the current task execution status
|
||||||
*/
|
*/
|
||||||
public getTaskStatus(session: Session): 'running' | 'completed' | 'pending' | 'failed' | 'cancelled' {
|
public getTaskStatus(session: Session): 'running' | 'paused' | 'completed' | 'pending' | 'failed' | 'cancelled' {
|
||||||
if (session.session_id === this.agentService.runningSessionId() && this.agentService.agentStatus() === 'running') {
|
if (session.session_id === this.agentService.runningSessionId() && (this.agentService.agentStatus() === 'running' || this.agentService.agentStatus() === 'paused')) {
|
||||||
return 'running';
|
return this.agentService.agentStatus() as 'running' | 'paused';
|
||||||
}
|
}
|
||||||
if (session.status) {
|
if (session.status) {
|
||||||
const s = session.status.toLowerCase();
|
const s = session.status.toLowerCase();
|
||||||
if (s === 'running' || s === 'completed' || s === 'pending' || s === 'failed' || s === 'cancelled') {
|
if (s === 'running' || s === 'paused' || s === 'completed' || s === 'pending' || s === 'failed' || s === 'cancelled') {
|
||||||
return s as any;
|
return s as any;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export interface TaskQueueItem {
|
|||||||
session_id: string;
|
session_id: string;
|
||||||
goal: string;
|
goal: string;
|
||||||
profile?: string;
|
profile?: string;
|
||||||
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
|
status: 'pending' | 'running' | 'paused' | 'completed' | 'failed' | 'cancelled';
|
||||||
created_at?: number;
|
created_at?: number;
|
||||||
start_time?: number;
|
start_time?: number;
|
||||||
}
|
}
|
||||||
@@ -47,9 +47,10 @@ export interface AgentStatusResponse {
|
|||||||
queue?: (TaskQueueItem | string)[];
|
queue?: (TaskQueueItem | string)[];
|
||||||
background_tasks?: any[];
|
background_tasks?: any[];
|
||||||
model_info?: ModelInfo | null;
|
model_info?: ModelInfo | null;
|
||||||
|
paused_error?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AgentStatus = 'idle' | 'running' | 'completed' | 'offline' | string;
|
export type AgentStatus = 'idle' | 'running' | 'completed' | 'offline' | string;
|
||||||
|
|
||||||
export type TaskStatus = 'running' | 'completed' | 'pending' | 'failed' | 'cancelled';
|
export type TaskStatus = 'running' | 'paused' | 'completed' | 'pending' | 'failed' | 'cancelled';
|
||||||
|
|
||||||
@@ -27,6 +27,7 @@ export type { Session, ModelInfo, TaskQueueItem, AgentStatusResponse, StepItemDa
|
|||||||
})
|
})
|
||||||
export class AgentService {
|
export class AgentService {
|
||||||
private http = inject(HttpClient);
|
private http = inject(HttpClient);
|
||||||
|
private activePauseCardKey: string | null = null;
|
||||||
|
|
||||||
// Signals to expose state to components
|
// Signals to expose state to components
|
||||||
private rawSessions = signal<Session[]>([]);
|
private rawSessions = signal<Session[]>([]);
|
||||||
@@ -43,38 +44,38 @@ export class AgentService {
|
|||||||
|
|
||||||
// 1. First add raw sessions from DB
|
// 1. First add raw sessions from DB
|
||||||
raw.forEach((s) => {
|
raw.forEach((s) => {
|
||||||
const isCurrentRunning = status === 'running' && runId === s.session_id;
|
const isCurrentActive = (status === 'running' || status === 'paused') && runId === s.session_id;
|
||||||
const isPending = this.pendingQueue().some(p => p.session_id === s.session_id) && !isCurrentRunning;
|
const isPending = this.pendingQueue().some(p => p.session_id === s.session_id) && !isCurrentActive;
|
||||||
let sStatus = isCurrentRunning ? 'running' : (isPending ? 'pending' : s.status);
|
let sStatus = isCurrentActive ? status : (isPending ? 'pending' : s.status);
|
||||||
if (sStatus === 'running' && !isCurrentRunning) {
|
if ((sStatus === 'running' || sStatus === 'paused') && !isCurrentActive) {
|
||||||
const activeRun = raw.find(r => r.session_id === runId);
|
const activeRun = raw.find(r => r.session_id === runId);
|
||||||
sStatus = (activeRun && (s.start_time || 0) > (activeRun.start_time || 0)) ? 'pending' : 'completed';
|
sStatus = (activeRun && (s.start_time || 0) > (activeRun.start_time || 0)) ? 'pending' : 'completed';
|
||||||
}
|
}
|
||||||
sessionMap.set(s.session_id, {
|
sessionMap.set(s.session_id, {
|
||||||
...s,
|
...s,
|
||||||
status: sStatus,
|
status: sStatus,
|
||||||
model_info: isCurrentRunning && this.activeModel() ? this.activeModel()! : s.model_info
|
model_info: isCurrentActive && this.activeModel() ? this.activeModel()! : s.model_info
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. Add pending queue sessions if not yet in raw sessions
|
// 2. Add pending queue sessions if not yet in raw sessions
|
||||||
pending.forEach((p) => {
|
pending.forEach((p) => {
|
||||||
if (!sessionMap.has(p.session_id)) {
|
if (!sessionMap.has(p.session_id)) {
|
||||||
const isCurrentRunning = status === 'running' && runId === p.session_id;
|
const isCurrentRunning = (status === 'running' || status === 'paused') && runId === p.session_id;
|
||||||
sessionMap.set(p.session_id, {
|
sessionMap.set(p.session_id, {
|
||||||
...p,
|
...p,
|
||||||
status: isCurrentRunning ? 'running' : 'pending'
|
status: isCurrentRunning ? status : 'pending'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. Ensure currently active running session is present
|
// 3. Ensure currently active running session is present
|
||||||
if (status === 'running' && runId && !sessionMap.has(runId)) {
|
if ((status === 'running' || status === 'paused') && runId && !sessionMap.has(runId)) {
|
||||||
sessionMap.set(runId, {
|
sessionMap.set(runId, {
|
||||||
session_id: runId,
|
session_id: runId,
|
||||||
initial_goal: goal || '',
|
initial_goal: goal || '',
|
||||||
start_time: Date.now() / 1000,
|
start_time: Date.now() / 1000,
|
||||||
status: 'running',
|
status,
|
||||||
model_info: this.activeModel() || undefined
|
model_info: this.activeModel() || undefined
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -127,8 +128,8 @@ export class AgentService {
|
|||||||
*/
|
*/
|
||||||
public isCurrentSessionRunning = computed(() => {
|
public isCurrentSessionRunning = computed(() => {
|
||||||
const session = this.currentSession();
|
const session = this.currentSession();
|
||||||
if (session) return session.status === 'running';
|
if (session) return session.status === 'running' || session.status === 'paused';
|
||||||
return this.agentStatus() === 'running';
|
return this.agentStatus() === 'running' || this.agentStatus() === 'paused';
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -153,8 +154,8 @@ export class AgentService {
|
|||||||
* Check if any task is currently running
|
* Check if any task is currently running
|
||||||
*/
|
*/
|
||||||
public isRunningTask = computed(() => {
|
public isRunningTask = computed(() => {
|
||||||
if (this.agentStatus() === 'running') return true;
|
if (this.agentStatus() === 'running' || this.agentStatus() === 'paused') return true;
|
||||||
return this.sessions().some(s => s.status === 'running');
|
return this.sessions().some(s => s.status === 'running' || s.status === 'paused');
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -246,6 +247,7 @@ export class AgentService {
|
|||||||
next: () => {
|
next: () => {
|
||||||
this.isPaused.set(false);
|
this.isPaused.set(false);
|
||||||
this.pausedError.set(null);
|
this.pausedError.set(null);
|
||||||
|
this.fetchStatus();
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
console.error('Failed to resume task:', err);
|
console.error('Failed to resume task:', err);
|
||||||
@@ -381,6 +383,7 @@ export class AgentService {
|
|||||||
|
|
||||||
this.currentSessionId.set(sessionId);
|
this.currentSessionId.set(sessionId);
|
||||||
this.sessionLogs.set([]); // Reset logs for new session selection
|
this.sessionLogs.set([]); // Reset logs for new session selection
|
||||||
|
this.activePauseCardKey = null;
|
||||||
this.isPaused.set(false);
|
this.isPaused.set(false);
|
||||||
this.pausedError.set(null);
|
this.pausedError.set(null);
|
||||||
this.fetchNotes(sessionId);
|
this.fetchNotes(sessionId);
|
||||||
@@ -411,7 +414,24 @@ export class AgentService {
|
|||||||
data: step
|
data: step
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
this.sessionLogs.set(historicalLogs);
|
// The history request and EventSource start together. On slower Windows
|
||||||
|
// hosts, live events can arrive first; replacing the signal here would
|
||||||
|
// erase them until a browser refresh reloads the persisted steps.
|
||||||
|
this.sessionLogs.update((liveLogs) => {
|
||||||
|
if (liveLogs.length === 0) return historicalLogs;
|
||||||
|
|
||||||
|
const liveStepIds = new Set(
|
||||||
|
liveLogs
|
||||||
|
.filter((log) => log.type === 'step_recorded' || log.type === 'step_updated')
|
||||||
|
.map((log) => String(log.data?.step_id || log.data?.step_number || ''))
|
||||||
|
.filter(Boolean)
|
||||||
|
);
|
||||||
|
const missingHistorical = historicalLogs.filter((log) => {
|
||||||
|
const stepKey = String(log.data?.step_id || log.data?.step_number || '');
|
||||||
|
return !stepKey || !liveStepIds.has(stepKey);
|
||||||
|
});
|
||||||
|
return [...missingHistorical, ...liveLogs];
|
||||||
|
});
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
console.error('Failed to pre-load historical steps:', err);
|
console.error('Failed to pre-load historical steps:', err);
|
||||||
@@ -507,7 +527,14 @@ export class AgentService {
|
|||||||
if (eventType === 'task_paused') {
|
if (eventType === 'task_paused') {
|
||||||
this.isPaused.set(true);
|
this.isPaused.set(true);
|
||||||
this.isRetrying.set(false);
|
this.isRetrying.set(false);
|
||||||
this.pausedError.set(parsedData.error || 'AI call failed');
|
const pauseError = parsedData.error || 'AI call failed';
|
||||||
|
this.pausedError.set(pauseError);
|
||||||
|
this.appendPausedErrorCard(
|
||||||
|
pauseError,
|
||||||
|
sessionId,
|
||||||
|
parsedData.timestamp,
|
||||||
|
parsedData.step_id
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -515,6 +542,7 @@ export class AgentService {
|
|||||||
this.isPaused.set(false);
|
this.isPaused.set(false);
|
||||||
this.isRetrying.set(false);
|
this.isRetrying.set(false);
|
||||||
this.pausedError.set(null);
|
this.pausedError.set(null);
|
||||||
|
this.activePauseCardKey = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -637,6 +665,63 @@ export class AgentService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preserve pause failures in the normal failed-LLM card stream. New runners
|
||||||
|
* emit a persisted failed trace first; this fallback also supports older
|
||||||
|
* runners whose task_paused event only carried an error string.
|
||||||
|
*/
|
||||||
|
private appendPausedErrorCard(
|
||||||
|
error: unknown,
|
||||||
|
sessionId: string,
|
||||||
|
timestamp?: number | string,
|
||||||
|
stepId?: string | null
|
||||||
|
): void {
|
||||||
|
const errorText = typeof error === 'string' ? error : JSON.stringify(error);
|
||||||
|
const pauseKey = `${sessionId}:${errorText}`;
|
||||||
|
if (this.activePauseCardKey === pauseKey) return;
|
||||||
|
|
||||||
|
this.sessionLogs.update((logs) => {
|
||||||
|
const alreadyRecorded = logs.some((log) => {
|
||||||
|
const traces = log?.type === 'trace_recorded'
|
||||||
|
? [log.data]
|
||||||
|
: (Array.isArray(log?.data?.generic_tools) ? log.data.generic_tools : []);
|
||||||
|
return traces.some((trace: any) =>
|
||||||
|
trace?.type === 'llm_call'
|
||||||
|
&& trace?.status === 'failed'
|
||||||
|
&& String(trace?.payload?.error ?? trace?.error ?? '') === errorText
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (alreadyRecorded) return logs;
|
||||||
|
|
||||||
|
const latestStepId = stepId || [...logs].reverse().find((log) =>
|
||||||
|
(log.type === 'step_updated' || log.type === 'step_recorded') && log.data?.step_id
|
||||||
|
)?.data?.step_id || null;
|
||||||
|
const timestampMs = typeof timestamp === 'number'
|
||||||
|
? (timestamp < 1e11 ? timestamp * 1000 : timestamp)
|
||||||
|
: (timestamp ? new Date(timestamp).getTime() : Date.now());
|
||||||
|
|
||||||
|
return [
|
||||||
|
...logs,
|
||||||
|
{
|
||||||
|
type: 'trace_recorded',
|
||||||
|
session_id: sessionId,
|
||||||
|
timestamp: new Date(timestampMs).toISOString(),
|
||||||
|
data: {
|
||||||
|
trace_id: `task-paused-${sessionId}-${timestampMs}`,
|
||||||
|
session_id: sessionId,
|
||||||
|
step_id: latestStepId,
|
||||||
|
type: 'llm_call',
|
||||||
|
name: 'llm_pause',
|
||||||
|
status: 'failed',
|
||||||
|
timestamp: timestampMs / 1000,
|
||||||
|
payload: { error: errorText, pause: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
});
|
||||||
|
this.activePauseCardKey = pauseKey;
|
||||||
|
}
|
||||||
|
|
||||||
private pollCounter: number = 0;
|
private pollCounter: number = 0;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -665,7 +750,8 @@ export class AgentService {
|
|||||||
const oldRunningSessionId = this.runningSessionId();
|
const oldRunningSessionId = this.runningSessionId();
|
||||||
|
|
||||||
this.agentStatus.set(data.status);
|
this.agentStatus.set(data.status);
|
||||||
if (data.status === 'running') {
|
const isActive = data.status === 'running' || data.status === 'paused';
|
||||||
|
if (isActive) {
|
||||||
this.runningSessionId.set(data.session_id || null);
|
this.runningSessionId.set(data.session_id || null);
|
||||||
this.runningGoal.set(data.goal || null);
|
this.runningGoal.set(data.goal || null);
|
||||||
if (data.model_info) {
|
if (data.model_info) {
|
||||||
@@ -689,6 +775,21 @@ export class AgentService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.isPaused.set(data.status === 'paused');
|
||||||
|
if (data.status === 'paused') {
|
||||||
|
this.isRetrying.set(false);
|
||||||
|
const pauseError = data.paused_error
|
||||||
|
|| this.pausedError()
|
||||||
|
|| 'AI model request failed. The task is paused.';
|
||||||
|
this.pausedError.set(pauseError);
|
||||||
|
if (data.session_id && this.currentSessionId() === data.session_id) {
|
||||||
|
this.appendPausedErrorCard(pauseError, data.session_id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.pausedError.set(null);
|
||||||
|
this.activePauseCardKey = null;
|
||||||
|
}
|
||||||
|
|
||||||
// Map backend pending queue to real Session / TaskQueueItem objects
|
// Map backend pending queue to real Session / TaskQueueItem objects
|
||||||
const pending: Session[] = (data.queue || []).map((item: any, index: number) => {
|
const pending: Session[] = (data.queue || []).map((item: any, index: number) => {
|
||||||
if (typeof item === 'object' && item !== null) {
|
if (typeof item === 'object' && item !== null) {
|
||||||
@@ -713,7 +814,7 @@ export class AgentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Auto-select the session if running and user hasn't explicitly chosen to inspect another task
|
// Auto-select the session if running and user hasn't explicitly chosen to inspect another task
|
||||||
if (data.status === 'running' && data.session_id) {
|
if (isActive && data.session_id) {
|
||||||
const currentId = this.currentSessionId();
|
const currentId = this.currentSessionId();
|
||||||
const pinnedId = this.userPinnedSessionId();
|
const pinnedId = this.userPinnedSessionId();
|
||||||
if (!currentId) {
|
if (!currentId) {
|
||||||
|
|||||||
@@ -63,7 +63,8 @@ def write_ipc_port(port: int) -> Path:
|
|||||||
|
|
||||||
|
|
||||||
def clear_ipc_port() -> None:
|
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()
|
port_file = get_ipc_port_file()
|
||||||
if port_file.exists():
|
if port_file.exists():
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import os
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -73,6 +74,21 @@ class EmulatorManager:
|
|||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
self._reader_thread: threading.Thread | None = None
|
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:
|
def _locate_emulator(self) -> str | None:
|
||||||
"""Find the emulator binary path from PATH or standard SDK environments."""
|
"""Find the emulator binary path from PATH or standard SDK environments."""
|
||||||
resolved = toolchain.resolve("emulator")
|
resolved = toolchain.resolve("emulator")
|
||||||
@@ -191,7 +207,7 @@ class EmulatorManager:
|
|||||||
stderr=subprocess.STDOUT,
|
stderr=subprocess.STDOUT,
|
||||||
text=True,
|
text=True,
|
||||||
bufsize=1,
|
bufsize=1,
|
||||||
start_new_session=True,
|
**self._subprocess_creation_kwargs(),
|
||||||
)
|
)
|
||||||
self._proc = proc
|
self._proc = proc
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import time
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import UUID, uuid4
|
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.context import ArtemisContext
|
||||||
from artemis.data_engine.models import (
|
from artemis.data_engine.models import (
|
||||||
BackgroundTaskRecord,
|
BackgroundTaskRecord,
|
||||||
@@ -82,17 +82,80 @@ class DataEngine:
|
|||||||
# Background tasks are written directly to SQLite storage
|
# Background tasks are written directly to SQLite storage
|
||||||
|
|
||||||
self.ipc_socket = None
|
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()
|
@staticmethod
|
||||||
if ipc_port:
|
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:
|
try:
|
||||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
file_value = port_file.read_text(encoding="utf-8").strip()
|
||||||
s.connect(("127.0.0.1", ipc_port))
|
if file_value.isdigit() and int(file_value) not in candidates:
|
||||||
s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
candidates.append(int(file_value))
|
||||||
self.ipc_socket = s
|
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")
|
logger.info(f"Connected to IPC server on port {ipc_port} with TCP_NODELAY")
|
||||||
except Exception as e:
|
return True
|
||||||
logger.error(f"Failed to connect to IPC server on port {ipc_port}: {e}")
|
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
|
@property
|
||||||
def base_dir(self) -> Path:
|
def base_dir(self) -> Path:
|
||||||
@@ -144,28 +207,7 @@ class DataEngine:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Auto-reconnect to IPC socket if disconnected or not yet connected
|
self._send_ipc_event(event_type, data)
|
||||||
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
|
|
||||||
|
|
||||||
def start_session(self, goal: str, device_info: dict[str, Any] | None = None) -> UUID:
|
def start_session(self, goal: str, device_info: dict[str, Any] | None = None) -> UUID:
|
||||||
"""Start a new session."""
|
"""Start a new session."""
|
||||||
@@ -601,11 +643,14 @@ class DataEngine:
|
|||||||
if thread.is_alive():
|
if thread.is_alive():
|
||||||
await asyncio.to_thread(thread.join)
|
await asyncio.to_thread(thread.join)
|
||||||
|
|
||||||
if getattr(self, "ipc_socket", None):
|
with self._ipc_socket_lock:
|
||||||
|
self._ipc_shutdown = True
|
||||||
|
if self.ipc_socket is not None:
|
||||||
try:
|
try:
|
||||||
self.ipc_socket.close()
|
self.ipc_socket.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
self.ipc_socket = None
|
||||||
|
|
||||||
logger.info("DataEngine shutdown complete. All data persisted.")
|
logger.info("DataEngine shutdown complete. All data persisted.")
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,18 @@ import typer
|
|||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
console = Console()
|
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:
|
def _get_vscode_user_dir() -> Path:
|
||||||
"""Returns the platform-specific VS Code user configuration directory."""
|
"""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 = {
|
config_body = {
|
||||||
"command": python_exe,
|
"command": python_exe,
|
||||||
"args": ["-m", "mcp_server"],
|
"args": ["-m", "mcp_server"],
|
||||||
"cwd": project_root,
|
|
||||||
"env": {
|
"env": {
|
||||||
"PYTHONUNBUFFERED": "1",
|
"PYTHONUNBUFFERED": "1",
|
||||||
"PYTHONPATH": project_root,
|
"PYTHONPATH": project_root,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
config_with_cwd = {**config_body, "cwd": project_root}
|
||||||
|
|
||||||
if client in ("antigravity", "jetski"):
|
if client == "antigravity":
|
||||||
return {
|
return {
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"artemis": {
|
"artemis": {
|
||||||
**config_body,
|
**config_with_cwd,
|
||||||
"tools": {
|
# This is the documented way to expose every tool. Avoid
|
||||||
"mobile_run_task": {"eager": True},
|
# proprietary eager fields in the current config so a
|
||||||
"mobile_manage_task": {"eager": True},
|
# strict parser can always load the server.
|
||||||
"mobile_get_device_state": {"eager": True},
|
"disabledTools": [],
|
||||||
"mobile_inspect_trace": {"eager": True},
|
}
|
||||||
},
|
}
|
||||||
|
}
|
||||||
|
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":
|
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":
|
elif client == "codex":
|
||||||
return {"mcp_servers": {"artemis": config_body}}
|
return {
|
||||||
else: # cursor, windsurf, claude, vscode, cline, roo, generic
|
"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}}
|
return {"mcpServers": {"artemis": config_body}}
|
||||||
|
|
||||||
|
|
||||||
@@ -100,7 +174,13 @@ def _parse_json_lenient(text: str) -> dict:
|
|||||||
return {}
|
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."""
|
"""Merges server configuration into a target JSON file without overwriting existing servers."""
|
||||||
try:
|
try:
|
||||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
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:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if key_name not in data or not isinstance(data.get(key_name), dict):
|
for remove_path in remove_paths:
|
||||||
data[key_name] = {}
|
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")
|
file_path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -141,6 +236,15 @@ def _codex_toml_block(server_config: dict) -> str:
|
|||||||
f"args = {args}",
|
f"args = {args}",
|
||||||
f"cwd = {cwd}",
|
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:
|
if env:
|
||||||
lines.extend(["", "[mcp_servers.artemis.env]"])
|
lines.extend(["", "[mcp_servers.artemis.env]"])
|
||||||
for key, value in env.items():
|
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:
|
for target in targets:
|
||||||
snippet = _get_config_snippet(target, python_exe, project_root)
|
snippet = _get_config_snippet(target, python_exe, project_root)
|
||||||
if target in ("antigravity", "jetski"):
|
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"
|
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"
|
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))
|
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))
|
installed_paths.append(str(config_path))
|
||||||
elif target in ("claude", "claude_code", "claude_desktop"):
|
elif target in ("claude", "claude_code", "claude_desktop"):
|
||||||
server_cfg = snippet["mcpServers"]["artemis"]
|
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):
|
if _merge_json_file(windsurf_path, "artemis", server_cfg):
|
||||||
installed_paths.append(str(windsurf_path))
|
installed_paths.append(str(windsurf_path))
|
||||||
elif target == "vscode":
|
elif target == "vscode":
|
||||||
server_cfg = snippet["mcpServers"]["artemis"]
|
server_cfg = snippet["servers"]["artemis"]
|
||||||
vscode_path = _get_vscode_user_dir() / "mcp.json"
|
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))
|
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":
|
elif target == "cline":
|
||||||
server_cfg = snippet["mcpServers"]["artemis"]
|
server_cfg = snippet["mcpServers"]["artemis"]
|
||||||
cline_path = _get_vscode_user_dir() / "globalStorage" / "saoudrizwan.claude-dev" / "settings" / "cline_mcp_settings.json"
|
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):
|
if _merge_json_file(cline_path, "artemis", server_cfg):
|
||||||
installed_paths.append(str(cline_path))
|
installed_paths.append(str(cline_path))
|
||||||
elif target in ("roo", "roo_code"):
|
elif target in ("roo", "roo_code"):
|
||||||
server_cfg = snippet["mcpServers"]["artemis"]
|
server_cfg = snippet["mcpServers"]["artemis"]
|
||||||
roo_path = _get_vscode_user_dir() / "globalStorage" / "rooveterinaryinc.roo-cline" / "settings" / "cline_mcp_settings.json"
|
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):
|
if _merge_json_file(roo_path, "artemis", server_cfg):
|
||||||
installed_paths.append(str(roo_path))
|
installed_paths.append(str(roo_path))
|
||||||
elif target == "openclaw":
|
elif target == "openclaw":
|
||||||
plugin_cfg = snippet["plugins"]["artemis_mcp"]
|
server_cfg = snippet["mcp"]["servers"]["artemis"]
|
||||||
openclaw_path = Path.home() / ".openclaw" / "openclaw.json"
|
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))
|
installed_paths.append(str(openclaw_path))
|
||||||
elif target == "codex":
|
elif target == "codex":
|
||||||
server_cfg = snippet["mcp_servers"]["artemis"]
|
server_cfg = snippet["mcp_servers"]["artemis"]
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
|
|
||||||
"""UI Launch and Web Console CLI Command (artemis ui)."""
|
"""UI Launch and Web Console CLI Command (artemis ui)."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
@@ -25,6 +27,27 @@ from rich.panel import Panel
|
|||||||
import typer
|
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:
|
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."""
|
"""Poll the UI server until it responds, then launch the user's default browser."""
|
||||||
start = time.time()
|
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 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"🛠️ [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]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))
|
console.print(Panel(msg, title="🚀 Web Server Active", border_style="cyan", expand=False))
|
||||||
# Ensure .env exists in workspace root
|
# Ensure .env exists in workspace root
|
||||||
@@ -94,22 +121,15 @@ def ui_command(
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
base_dist = ROOT_DIR / "apps" / "showcase_ui" / "dist"
|
showcase_dir = ROOT_DIR / "apps" / "showcase_ui"
|
||||||
candidates = [
|
if _showcase_build_required(showcase_dir):
|
||||||
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):
|
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
if shutil.which("npm"):
|
if shutil.which("npm"):
|
||||||
console.print(
|
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:
|
try:
|
||||||
subprocess.run(["npm", "install", "--silent"], cwd=showcase_dir, check=True)
|
subprocess.run(["npm", "install", "--silent"], cwd=showcase_dir, check=True)
|
||||||
subprocess.run(["npm", "run", "build"], cwd=showcase_dir, check=True)
|
subprocess.run(["npm", "run", "build"], cwd=showcase_dir, check=True)
|
||||||
@@ -127,10 +147,9 @@ def ui_command(
|
|||||||
t.start()
|
t.start()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import uvicorn
|
from apps.admin_console.server import run_ui_server
|
||||||
from apps.admin_console.server import app
|
|
||||||
|
|
||||||
uvicorn.run(app, host=host, port=port, reload=reload)
|
run_ui_server(host=host, port=port, reload=reload)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
console.print("\n[yellow]🛑 UI Server stopped.[/yellow]")
|
console.print("\n[yellow]🛑 UI Server stopped.[/yellow]")
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -14,9 +14,12 @@
|
|||||||
|
|
||||||
"""Artemis cross-platform runtime and process supervisor subsystem."""
|
"""Artemis cross-platform runtime and process supervisor subsystem."""
|
||||||
|
|
||||||
|
from artemis.runtime.device_lock import DeviceBusyError, DeviceExecutionLock
|
||||||
from artemis.runtime.supervisor import ProcessSupervisor, process_supervisor
|
from artemis.runtime.supervisor import ProcessSupervisor, process_supervisor
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"DeviceBusyError",
|
||||||
|
"DeviceExecutionLock",
|
||||||
"ProcessSupervisor",
|
"ProcessSupervisor",
|
||||||
"process_supervisor",
|
"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
|
||||||
@@ -53,6 +53,7 @@ from artemis.controllers.controller_factory import get_controller
|
|||||||
from artemis.controllers.platform_specific_commands_controller import (
|
from artemis.controllers.platform_specific_commands_controller import (
|
||||||
get_first_device,
|
get_first_device,
|
||||||
)
|
)
|
||||||
|
from artemis.runtime import DeviceExecutionLock
|
||||||
from artemis.data_engine.engine import DataEngine
|
from artemis.data_engine.engine import DataEngine
|
||||||
from artemis.data_engine.trace import DataEngineCallbackHandler
|
from artemis.data_engine.trace import DataEngineCallbackHandler
|
||||||
from artemis.graph.graph import get_graph
|
from artemis.graph.graph import get_graph
|
||||||
@@ -469,7 +470,13 @@ class Agent:
|
|||||||
last_state: State | None = None
|
last_state: State | None = None
|
||||||
last_state_snapshot: dict | None = None
|
last_state_snapshot: dict | None = None
|
||||||
output = None
|
output = None
|
||||||
|
device_lock = DeviceExecutionLock(
|
||||||
|
self._device_context.device_id,
|
||||||
|
description=f"automation task: {request.goal[:120]}",
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
|
if os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
|
||||||
|
await asyncio.to_thread(device_lock.acquire)
|
||||||
async with context:
|
async with context:
|
||||||
recording_started = False
|
recording_started = False
|
||||||
if self._config.video_recording_tools_enabled:
|
if self._config.video_recording_tools_enabled:
|
||||||
@@ -634,7 +641,11 @@ class Agent:
|
|||||||
|
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
|
try:
|
||||||
await self._finalize_tracing(task=task, context=context)
|
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:
|
async with self._task_lock:
|
||||||
if self._current_task and not self._current_task.done():
|
if self._current_task and not self._current_task.done():
|
||||||
|
|||||||
+24
-1
@@ -25,6 +25,7 @@ import functools
|
|||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import random
|
import random
|
||||||
|
import time
|
||||||
from typing import Any, Literal, TypeVar, overload
|
from typing import Any, Literal, TypeVar, overload
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
@@ -67,7 +68,29 @@ def _handle_llm_pause_and_resume(last_error: Exception) -> Path:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
if _CURRENT_DATA_ENGINE:
|
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
|
return pause_file
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from typing import Any
|
|||||||
from mcp_server.base import mcp
|
from mcp_server.base import mcp
|
||||||
from mcp_server.notifiers import notify
|
from mcp_server.notifiers import notify
|
||||||
from mcp_server.utils import env_utils, trace_store
|
from mcp_server.utils import env_utils, trace_store
|
||||||
|
from artemis.runtime import DeviceExecutionLock, process_supervisor
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@@ -256,6 +257,17 @@ def mobile_manage_task(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
if sys.platform == "win32":
|
||||||
|
if not process_supervisor.terminate_tree(pid):
|
||||||
|
import psutil
|
||||||
|
|
||||||
|
if psutil.pid_exists(pid):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Failed to terminate Windows process tree rooted at {pid}"
|
||||||
|
)
|
||||||
|
DeviceExecutionLock.cleanup_stale_locks()
|
||||||
|
else:
|
||||||
|
# Preserve the existing POSIX signal behavior.
|
||||||
os.kill(pid, signal.SIGTERM)
|
os.kill(pid, signal.SIGTERM)
|
||||||
trace_store.update_trace_status(
|
trace_store.update_trace_status(
|
||||||
trace_id, "cancelled", error="Task aborted by user request."
|
trace_id, "cancelled", error="Task aborted by user request."
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from contextlib import suppress
|
||||||
|
import signal
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
from apps.admin_console.core.state import state
|
||||||
|
from apps.admin_console.routers.tasks import stream_events
|
||||||
|
from apps.admin_console.server import ArtemisUvicornServer, app, on_shutdown
|
||||||
|
|
||||||
|
WINDOWS_FORCE_SIGNAL = getattr(signal, "SIGBREAK", signal.SIGTERM)
|
||||||
|
|
||||||
|
|
||||||
|
def _pending_queue_get_tasks():
|
||||||
|
return {
|
||||||
|
task
|
||||||
|
for task in asyncio.all_tasks()
|
||||||
|
if not task.done() and task.get_coro().__qualname__ == "Queue.get"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cancelled_event_stream_reaps_queue_waiter():
|
||||||
|
state.is_shutting_down = False
|
||||||
|
state.shutdown_event.clear()
|
||||||
|
subscribers_before = list(state.ipc_subscribers)
|
||||||
|
queue_tasks_before = _pending_queue_get_tasks()
|
||||||
|
|
||||||
|
response = await stream_events("test-session")
|
||||||
|
iterator = response.body_iterator
|
||||||
|
first_event = await anext(iterator)
|
||||||
|
assert "Subscribed to session test-session" in first_event
|
||||||
|
|
||||||
|
next_event = asyncio.create_task(anext(iterator))
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
next_event.cancel()
|
||||||
|
with suppress(asyncio.CancelledError):
|
||||||
|
await next_event
|
||||||
|
await iterator.aclose()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
assert state.ipc_subscribers == subscribers_before
|
||||||
|
assert _pending_queue_get_tasks() == queue_tasks_before
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_windows_sigint_is_ignored_while_task_is_active():
|
||||||
|
state.current_process = MagicMock(returncode=None)
|
||||||
|
state.is_shutting_down = False
|
||||||
|
state.shutdown_event.clear()
|
||||||
|
server = ArtemisUvicornServer(uvicorn.Config(app))
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("apps.admin_console.server.sys.platform", "win32"),
|
||||||
|
patch.object(server, "_schedule_signal_report") as report,
|
||||||
|
patch.object(uvicorn.Server, "handle_exit") as base_handle_exit,
|
||||||
|
):
|
||||||
|
server.handle_exit(signal.SIGINT, None)
|
||||||
|
|
||||||
|
base_handle_exit.assert_not_called()
|
||||||
|
assert report.call_count == 2
|
||||||
|
assert state.is_shutting_down is False
|
||||||
|
assert state.shutdown_event.is_set() is False
|
||||||
|
state.current_process = None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("platform_name", "exit_signal"),
|
||||||
|
[("linux", signal.SIGINT), ("win32", WINDOWS_FORCE_SIGNAL)],
|
||||||
|
)
|
||||||
|
async def test_non_windows_sigint_and_windows_sigbreak_keep_uvicorn_behavior(
|
||||||
|
platform_name, exit_signal
|
||||||
|
):
|
||||||
|
state.current_process = MagicMock(returncode=None)
|
||||||
|
state.is_shutting_down = False
|
||||||
|
state.shutdown_event.clear()
|
||||||
|
server = ArtemisUvicornServer(uvicorn.Config(app))
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("apps.admin_console.server.sys.platform", platform_name),
|
||||||
|
patch.object(server, "_schedule_signal_report"),
|
||||||
|
patch.object(uvicorn.Server, "handle_exit") as base_handle_exit,
|
||||||
|
):
|
||||||
|
server.handle_exit(exit_signal, None)
|
||||||
|
|
||||||
|
base_handle_exit.assert_called_once_with(exit_signal, None)
|
||||||
|
assert state.is_shutting_down is True
|
||||||
|
assert state.shutdown_event.is_set() is True
|
||||||
|
state.current_process = None
|
||||||
|
state.is_shutting_down = False
|
||||||
|
state.shutdown_event.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_shutdown_marks_only_ui_owned_running_sessions():
|
||||||
|
state.queue_items = [
|
||||||
|
{"session_id": "ui-running", "status": "running"},
|
||||||
|
{"session_id": "ui-pending", "status": "pending"},
|
||||||
|
]
|
||||||
|
state.active_session_id = "external-mcp-session"
|
||||||
|
state.current_process = None
|
||||||
|
state.worker_task = None
|
||||||
|
|
||||||
|
update_status = MagicMock()
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"apps.admin_console.server.session_repo.update_session_status",
|
||||||
|
update_status,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"apps.admin_console.server.ipc_service.stop_server",
|
||||||
|
new=AsyncMock(),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
await on_shutdown()
|
||||||
|
|
||||||
|
update_status.assert_called_once_with("ui-running", "cancelled")
|
||||||
|
assert state.queue_items == []
|
||||||
|
state.active_session_id = None
|
||||||
|
state.is_shutting_down = False
|
||||||
|
state.shutdown_event.clear()
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
from apps.admin_console.database.repositories.step_repository import StepRepository
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_pause_log_is_normalized_to_failed_llm_trace():
|
||||||
|
repository = StepRepository()
|
||||||
|
trace = {
|
||||||
|
"trace_id": "pause-log",
|
||||||
|
"type": "log",
|
||||||
|
"name": "artemis.services.llm",
|
||||||
|
"status": "success",
|
||||||
|
"payload": json.dumps(
|
||||||
|
{
|
||||||
|
"message": (
|
||||||
|
"LLM Error: 503 UNAVAILABLE. {'error': {'message': 'High demand'}}. "
|
||||||
|
"Pausing execution to wait for resume signal..."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized = repository._normalize_display_trace(trace)
|
||||||
|
|
||||||
|
assert normalized["type"] == "llm_call"
|
||||||
|
assert normalized["name"] == "llm_pause"
|
||||||
|
assert normalized["status"] == "failed"
|
||||||
|
assert normalized["payload"]["pause"] is True
|
||||||
|
assert normalized["payload"]["error"].startswith("503 UNAVAILABLE")
|
||||||
|
|
||||||
|
|
||||||
|
def test_unrelated_log_is_not_presented_as_llm_failure():
|
||||||
|
repository = StepRepository()
|
||||||
|
trace = {
|
||||||
|
"trace_id": "ordinary-log",
|
||||||
|
"type": "log",
|
||||||
|
"name": "artemis.services.llm",
|
||||||
|
"status": "success",
|
||||||
|
"payload": json.dumps({"message": "LLM request completed"}),
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized = repository._normalize_display_trace(trace)
|
||||||
|
|
||||||
|
assert normalized["type"] == "log"
|
||||||
|
assert normalized["status"] == "success"
|
||||||
@@ -14,10 +14,29 @@
|
|||||||
|
|
||||||
"""Unit tests for System Emulator Launch & Lifecycle Endpoints."""
|
"""Unit tests for System Emulator Launch & Lifecycle Endpoints."""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
from apps.admin_console.server import app
|
from apps.admin_console.server import app
|
||||||
|
from artemis.core.diagnostics.emulator_manager import EmulatorManager
|
||||||
|
|
||||||
|
|
||||||
|
def test_emulator_uses_posix_session_isolation(monkeypatch):
|
||||||
|
monkeypatch.setattr(sys, "platform", "linux")
|
||||||
|
|
||||||
|
assert EmulatorManager._subprocess_creation_kwargs() == {"start_new_session": True}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(sys.platform != "win32", reason="Windows creation flags only exist on Windows")
|
||||||
|
def test_emulator_uses_windows_console_isolation():
|
||||||
|
kwargs = EmulatorManager._subprocess_creation_kwargs()
|
||||||
|
|
||||||
|
assert "start_new_session" not in kwargs
|
||||||
|
assert kwargs["creationflags"] & subprocess.CREATE_NEW_PROCESS_GROUP
|
||||||
|
assert kwargs["creationflags"] & subprocess.CREATE_NO_WINDOW
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import importlib
|
||||||
|
import sys
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -21,8 +23,13 @@ from apps.admin_console.services.task_queue_service import TaskQueueService, tas
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def clean_state():
|
def clean_state(tmp_path, monkeypatch):
|
||||||
"""Reset global state between tests."""
|
"""Reset global state between tests."""
|
||||||
|
isolated_pause_file = tmp_path / ".artemis_paused"
|
||||||
|
state_module = importlib.import_module("apps.admin_console.core.state")
|
||||||
|
queue_module = importlib.import_module("apps.admin_console.services.task_queue_service")
|
||||||
|
monkeypatch.setattr(state_module, "PAUSE_FILE", isolated_pause_file)
|
||||||
|
monkeypatch.setattr(queue_module, "PAUSE_FILE", isolated_pause_file)
|
||||||
state.clear_queue()
|
state.clear_queue()
|
||||||
state.queue_items.clear()
|
state.queue_items.clear()
|
||||||
state.current_process = None
|
state.current_process = None
|
||||||
@@ -41,6 +48,15 @@ def clean_state():
|
|||||||
state.worker_task = None
|
state.worker_task = None
|
||||||
|
|
||||||
|
|
||||||
|
def test_paused_error_reads_persisted_reason(tmp_path):
|
||||||
|
pause_file = tmp_path / ".artemis_paused"
|
||||||
|
pause_file.write_text("LLM Error: 503 UNAVAILABLE: model overloaded", encoding="utf-8")
|
||||||
|
|
||||||
|
with patch("apps.admin_console.core.state.PAUSE_FILE", pause_file):
|
||||||
|
assert state.is_paused is True
|
||||||
|
assert state.paused_error == "503 UNAVAILABLE: model overloaded"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_next_pending_task():
|
async def test_get_next_pending_task():
|
||||||
state.queue_items = [
|
state.queue_items = [
|
||||||
@@ -145,9 +161,11 @@ async def test_queue_worker_execution_lifecycle():
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_queue_worker_cmd_construction():
|
async def test_queue_worker_cmd_construction():
|
||||||
executed_cmds = []
|
executed_cmds = []
|
||||||
|
executed_kwargs = []
|
||||||
|
|
||||||
async def fake_subprocess_exec(*args, **kwargs):
|
async def fake_subprocess_exec(*args, **kwargs):
|
||||||
executed_cmds.append(list(args))
|
executed_cmds.append(list(args))
|
||||||
|
executed_kwargs.append(kwargs)
|
||||||
proc = MagicMock()
|
proc = MagicMock()
|
||||||
proc.pid = 88888
|
proc.pid = 88888
|
||||||
proc.wait = AsyncMock(return_value=0)
|
proc.wait = AsyncMock(return_value=0)
|
||||||
@@ -184,6 +202,12 @@ async def test_queue_worker_cmd_construction():
|
|||||||
assert "com.google.android.apps.maps" in cmd
|
assert "com.google.android.apps.maps" in cmd
|
||||||
assert "--app-path" in cmd
|
assert "--app-path" in cmd
|
||||||
assert "/path/to/app.apk" in cmd
|
assert "/path/to/app.apk" in cmd
|
||||||
|
if sys.platform == "win32":
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
assert executed_kwargs[0]["creationflags"] == subprocess.CREATE_NEW_PROCESS_GROUP
|
||||||
|
else:
|
||||||
|
assert "creationflags" not in executed_kwargs[0]
|
||||||
|
|
||||||
task = state.worker_task
|
task = state.worker_task
|
||||||
if task and not task.done():
|
if task and not task.done():
|
||||||
@@ -244,7 +268,7 @@ async def test_cancel_task_triggers_next_pending_task():
|
|||||||
await task_queue_service.enqueue_tasks(["Task 1", "Task 2"])
|
await task_queue_service.enqueue_tasks(["Task 1", "Task 2"])
|
||||||
|
|
||||||
for _ in range(40):
|
for _ in range(40):
|
||||||
if len(executed_goals) == 2:
|
if len(executed_goals) == 2 and len(state.queue_items) == 0:
|
||||||
break
|
break
|
||||||
await asyncio.sleep(0.05)
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
|||||||
@@ -12,11 +12,95 @@
|
|||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
from unittest.mock import MagicMock
|
import asyncio
|
||||||
|
import json
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from artemis.context import ArtemisContext
|
from artemis.context import ArtemisContext
|
||||||
from artemis.data_engine.engine import DataEngine
|
from artemis.data_engine.engine import DataEngine
|
||||||
|
|
||||||
|
|
||||||
|
def test_ipc_send_reconnects_and_retries_current_event(tmp_path):
|
||||||
|
"""A stale Windows TCP socket must not make the triggering SSE event disappear."""
|
||||||
|
mock_ctx = MagicMock(spec=ArtemisContext)
|
||||||
|
mock_execution_setup = MagicMock()
|
||||||
|
mock_execution_setup.traces_path = str(tmp_path)
|
||||||
|
mock_ctx.execution_setup = mock_execution_setup
|
||||||
|
mock_ctx.device = None
|
||||||
|
|
||||||
|
stale_socket = MagicMock()
|
||||||
|
stale_socket.sendall.side_effect = OSError("connection reset")
|
||||||
|
replacement_socket = MagicMock()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("artemis.data_engine.engine.read_ipc_port", return_value=49152),
|
||||||
|
patch(
|
||||||
|
"artemis.data_engine.engine.socket.create_connection",
|
||||||
|
side_effect=[stale_socket, replacement_socket],
|
||||||
|
) as create_connection,
|
||||||
|
):
|
||||||
|
engine = DataEngine(mock_ctx)
|
||||||
|
engine._publish("llm_stream", {"session_id": "s1", "chunk": "hello"})
|
||||||
|
|
||||||
|
assert create_connection.call_count == 2
|
||||||
|
stale_socket.close.assert_called_once()
|
||||||
|
replacement_socket.sendall.assert_called_once()
|
||||||
|
frame = replacement_socket.sendall.call_args.args[0]
|
||||||
|
assert frame.endswith(b"\n")
|
||||||
|
assert json.loads(frame.decode("utf-8"))["data"]["chunk"] == "hello"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ipc_connect_falls_back_to_refreshed_port_file(tmp_path):
|
||||||
|
"""A UI restart must supersede the worker's stale inherited Windows port."""
|
||||||
|
mock_ctx = MagicMock(spec=ArtemisContext)
|
||||||
|
mock_execution_setup = MagicMock()
|
||||||
|
mock_execution_setup.traces_path = str(tmp_path / "traces")
|
||||||
|
mock_ctx.execution_setup = mock_execution_setup
|
||||||
|
mock_ctx.device = None
|
||||||
|
|
||||||
|
port_file = tmp_path / ".artemis_ipc_port"
|
||||||
|
port_file.write_text("51629", encoding="utf-8")
|
||||||
|
live_socket = MagicMock()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("artemis.data_engine.engine.read_ipc_port", return_value=49555),
|
||||||
|
patch("artemis.data_engine.engine.get_ipc_port_file", return_value=port_file),
|
||||||
|
patch(
|
||||||
|
"artemis.data_engine.engine.socket.create_connection",
|
||||||
|
side_effect=[OSError("stale port"), live_socket],
|
||||||
|
) as create_connection,
|
||||||
|
):
|
||||||
|
engine = DataEngine(mock_ctx)
|
||||||
|
|
||||||
|
assert engine.ipc_socket is live_socket
|
||||||
|
assert create_connection.call_args_list[0].args[0] == ("127.0.0.1", 49555)
|
||||||
|
assert create_connection.call_args_list[1].args[0] == ("127.0.0.1", 51629)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ipc_does_not_reconnect_after_engine_shutdown(tmp_path):
|
||||||
|
mock_ctx = MagicMock(spec=ArtemisContext)
|
||||||
|
mock_execution_setup = MagicMock()
|
||||||
|
mock_execution_setup.traces_path = str(tmp_path)
|
||||||
|
mock_ctx.execution_setup = mock_execution_setup
|
||||||
|
mock_ctx.device = None
|
||||||
|
connected_socket = MagicMock()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("artemis.data_engine.engine.read_ipc_port", return_value=49152),
|
||||||
|
patch("artemis.data_engine.engine.get_ipc_port_file", return_value=tmp_path / "none"),
|
||||||
|
patch(
|
||||||
|
"artemis.data_engine.engine.socket.create_connection",
|
||||||
|
return_value=connected_socket,
|
||||||
|
) as create_connection,
|
||||||
|
):
|
||||||
|
engine = DataEngine(mock_ctx)
|
||||||
|
asyncio.run(engine.shutdown())
|
||||||
|
engine._publish("llm_stream", {"chunk": "after shutdown"})
|
||||||
|
|
||||||
|
assert create_connection.call_count == 1
|
||||||
|
connected_socket.close.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
def test_get_or_create_image_updates_missing_data(tmp_path):
|
def test_get_or_create_image_updates_missing_data(tmp_path):
|
||||||
# Setup mock context
|
# Setup mock context
|
||||||
mock_ctx = MagicMock(spec=ArtemisContext)
|
mock_ctx = MagicMock(spec=ArtemisContext)
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from artemis.runtime.device_lock import DeviceBusyError, DeviceExecutionLock
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def isolated_lock_directory(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"artemis.runtime.device_lock.get_temp_dir",
|
||||||
|
lambda _name: tmp_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_device_lock_blocks_a_second_process_owner():
|
||||||
|
first = DeviceExecutionLock("emulator-5554", "first task")
|
||||||
|
second = DeviceExecutionLock("emulator-5554", "second task")
|
||||||
|
|
||||||
|
first.acquire()
|
||||||
|
try:
|
||||||
|
with pytest.raises(DeviceBusyError, match="first task"):
|
||||||
|
second.acquire()
|
||||||
|
finally:
|
||||||
|
first.release()
|
||||||
|
|
||||||
|
|
||||||
|
def test_device_lock_recovers_stale_owner(monkeypatch):
|
||||||
|
lock = DeviceExecutionLock("emulator-5554", "replacement task")
|
||||||
|
lock.path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"pid": 999999,
|
||||||
|
"process_created_at": 1.0,
|
||||||
|
"token": "stale-token",
|
||||||
|
"device_id": "emulator-5554",
|
||||||
|
"description": "stale task",
|
||||||
|
"acquired_at": "2026-01-01T00:00:00+00:00",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(DeviceExecutionLock, "_owner_is_alive", staticmethod(lambda _owner: False))
|
||||||
|
|
||||||
|
lock.acquire()
|
||||||
|
try:
|
||||||
|
assert lock.path.exists()
|
||||||
|
finally:
|
||||||
|
lock.release()
|
||||||
|
|
||||||
|
assert not lock.path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_device_lock_release_does_not_remove_replacement_owner():
|
||||||
|
lock = DeviceExecutionLock("emulator-5554", "original task")
|
||||||
|
lock.acquire()
|
||||||
|
replacement = json.loads(lock.path.read_text(encoding="utf-8"))
|
||||||
|
replacement["token"] = "replacement-token"
|
||||||
|
lock.path.write_text(json.dumps(replacement), encoding="utf-8")
|
||||||
|
|
||||||
|
lock.release()
|
||||||
|
|
||||||
|
assert lock.path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_stale_locks_keeps_live_owner(monkeypatch):
|
||||||
|
live = DeviceExecutionLock("emulator-5554", "live task")
|
||||||
|
stale = DeviceExecutionLock("physical-device", "stale task")
|
||||||
|
live.acquire()
|
||||||
|
stale.path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"pid": 999999,
|
||||||
|
"process_created_at": 1.0,
|
||||||
|
"token": "stale-token",
|
||||||
|
"device_id": "physical-device",
|
||||||
|
"description": "stale task",
|
||||||
|
"acquired_at": "2026-01-01T00:00:00+00:00",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
original_owner_is_alive = DeviceExecutionLock._owner_is_alive
|
||||||
|
|
||||||
|
def owner_is_alive(owner):
|
||||||
|
if owner.pid == 999999:
|
||||||
|
return False
|
||||||
|
return original_owner_is_alive(owner)
|
||||||
|
|
||||||
|
monkeypatch.setattr(DeviceExecutionLock, "_owner_is_alive", staticmethod(owner_is_alive))
|
||||||
|
try:
|
||||||
|
assert DeviceExecutionLock.cleanup_stale_locks() == 1
|
||||||
|
assert live.path.exists()
|
||||||
|
assert not stale.path.exists()
|
||||||
|
finally:
|
||||||
|
live.release()
|
||||||
+106
-4
@@ -81,12 +81,28 @@ def test_cli_mcp_generate_config():
|
|||||||
|
|
||||||
|
|
||||||
def test_cli_mcp_generate_config_antigravity():
|
def test_cli_mcp_generate_config_antigravity():
|
||||||
"""Verify 'artemis mcp --generate-config antigravity' produces valid configuration with tools."""
|
"""Verify current Antigravity uses only documented, load-safe fields."""
|
||||||
|
from artemis.interfaces.cli.commands.mcp import _get_config_snippet
|
||||||
|
|
||||||
result = runner.invoke(app, ["mcp", "--generate-config", "antigravity"])
|
result = runner.invoke(app, ["mcp", "--generate-config", "antigravity"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "mcpServers" in result.output
|
assert "mcpServers" in result.output
|
||||||
assert "mobile_run_task" in result.output
|
server_config = _get_config_snippet("antigravity", "python", "/project")["mcpServers"][
|
||||||
assert "eager" in result.output
|
"artemis"
|
||||||
|
]
|
||||||
|
assert server_config["disabledTools"] == []
|
||||||
|
assert "tools" not in server_config
|
||||||
|
|
||||||
|
legacy_config = _get_config_snippet("jetski", "python", "/project")["mcpServers"][
|
||||||
|
"artemis"
|
||||||
|
]
|
||||||
|
assert set(legacy_config["tools"]) == {
|
||||||
|
"mobile_run_task",
|
||||||
|
"mobile_manage_task",
|
||||||
|
"mobile_get_device_state",
|
||||||
|
"mobile_inspect_trace",
|
||||||
|
}
|
||||||
|
assert all(tool["eager"] is True for tool in legacy_config["tools"].values())
|
||||||
|
|
||||||
|
|
||||||
def test_cli_mcp_generate_config_all():
|
def test_cli_mcp_generate_config_all():
|
||||||
@@ -104,11 +120,26 @@ def test_cli_mcp_generate_config_all():
|
|||||||
|
|
||||||
|
|
||||||
def test_cli_mcp_generate_config_codex():
|
def test_cli_mcp_generate_config_codex():
|
||||||
"""Verify Codex generation produces a ready-to-use TOML server block."""
|
"""Verify Codex initializes the server and enables every Artemis tool."""
|
||||||
|
from artemis.interfaces.cli.commands.mcp import _get_config_snippet
|
||||||
|
|
||||||
result = runner.invoke(app, ["mcp", "--generate-config", "codex"])
|
result = runner.invoke(app, ["mcp", "--generate-config", "codex"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "[mcp_servers.artemis]" in result.output
|
assert "[mcp_servers.artemis]" in result.output
|
||||||
assert "[mcp_servers.artemis.env]" in result.output
|
assert "[mcp_servers.artemis.env]" in result.output
|
||||||
|
assert "enabled = true" in result.output
|
||||||
|
assert "required = true" in result.output
|
||||||
|
assert "startup_timeout_sec = 120" in result.output
|
||||||
|
assert "enabled_tools" in result.output
|
||||||
|
assert "mobile_run_task" in result.output
|
||||||
|
assert "mobile_manage_task" in result.output
|
||||||
|
server_config = _get_config_snippet("codex", "python", "/project")["mcp_servers"]["artemis"]
|
||||||
|
assert server_config["enabled_tools"] == [
|
||||||
|
"mobile_run_task",
|
||||||
|
"mobile_manage_task",
|
||||||
|
"mobile_get_device_state",
|
||||||
|
"mobile_inspect_trace",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_cli_mcp_install_antigravity(tmp_path, monkeypatch):
|
def test_cli_mcp_install_antigravity(tmp_path, monkeypatch):
|
||||||
@@ -126,6 +157,11 @@ def test_cli_mcp_install_antigravity(tmp_path, monkeypatch):
|
|||||||
assert "mobile_run_task" in data["mcpServers"]["artemis"]["tools"]
|
assert "mobile_run_task" in data["mcpServers"]["artemis"]["tools"]
|
||||||
assert "PYTHONPATH" in data["mcpServers"]["artemis"]["env"]
|
assert "PYTHONPATH" in data["mcpServers"]["artemis"]["env"]
|
||||||
|
|
||||||
|
current_file = tmp_path / ".gemini" / "config" / "mcp_config.json"
|
||||||
|
current_data = json.loads(current_file.read_text())
|
||||||
|
assert current_data["mcpServers"]["artemis"]["disabledTools"] == []
|
||||||
|
assert "tools" not in current_data["mcpServers"]["artemis"]
|
||||||
|
|
||||||
# Verify global rule file installed
|
# Verify global rule file installed
|
||||||
gemini_md = tmp_path / ".gemini" / "GEMINI.md"
|
gemini_md = tmp_path / ".gemini" / "GEMINI.md"
|
||||||
assert gemini_md.exists()
|
assert gemini_md.exists()
|
||||||
@@ -138,6 +174,7 @@ def test_cli_mcp_install_antigravity(tmp_path, monkeypatch):
|
|||||||
def test_cli_mcp_install_all(tmp_path, monkeypatch):
|
def test_cli_mcp_install_all(tmp_path, monkeypatch):
|
||||||
"""Verify 'artemis mcp --install all' installs configs and global rules to all supported IDE locations."""
|
"""Verify 'artemis mcp --install all' installs configs and global rules to all supported IDE locations."""
|
||||||
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
|
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
|
||||||
|
monkeypatch.setenv("APPDATA", str(tmp_path / "AppData" / "Roaming"))
|
||||||
monkeypatch.setattr("mcp_server.utils.env_utils.get_project_root", lambda: str(tmp_path))
|
monkeypatch.setattr("mcp_server.utils.env_utils.get_project_root", lambda: str(tmp_path))
|
||||||
result = runner.invoke(app, ["mcp", "--install", "all"])
|
result = runner.invoke(app, ["mcp", "--install", "all"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
@@ -148,6 +185,39 @@ def test_cli_mcp_install_all(tmp_path, monkeypatch):
|
|||||||
assert (tmp_path / ".claude.json").exists()
|
assert (tmp_path / ".claude.json").exists()
|
||||||
assert (tmp_path / ".codex" / "config.toml").exists()
|
assert (tmp_path / ".codex" / "config.toml").exists()
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
cursor_data = json.loads((tmp_path / ".cursor" / "mcp.json").read_text())
|
||||||
|
assert cursor_data["mcpServers"]["artemis"]["command"]
|
||||||
|
|
||||||
|
windsurf_data = json.loads(
|
||||||
|
(tmp_path / ".codeium" / "windsurf" / "mcp_config.json").read_text()
|
||||||
|
)
|
||||||
|
assert windsurf_data["mcpServers"]["artemis"]["command"]
|
||||||
|
|
||||||
|
claude_data = json.loads((tmp_path / ".claude.json").read_text())
|
||||||
|
assert claude_data["mcpServers"]["artemis"]["type"] == "stdio"
|
||||||
|
|
||||||
|
vscode_data = json.loads(
|
||||||
|
(tmp_path / "AppData" / "Roaming" / "Code" / "User" / "mcp.json").read_text()
|
||||||
|
)
|
||||||
|
assert vscode_data["servers"]["artemis"]["type"] == "stdio"
|
||||||
|
assert "mcpServers" not in vscode_data
|
||||||
|
|
||||||
|
copilot_data = json.loads((tmp_path / ".copilot" / "mcp-config.json").read_text())
|
||||||
|
assert copilot_data["servers"]["artemis"]["type"] == "stdio"
|
||||||
|
|
||||||
|
openclaw_data = json.loads((tmp_path / ".openclaw" / "openclaw.json").read_text())
|
||||||
|
assert openclaw_data["mcp"]["servers"]["artemis"]["enabled"] is True
|
||||||
|
|
||||||
|
cline_data = json.loads(
|
||||||
|
(tmp_path / ".cline" / "data" / "settings" / "cline_mcp_settings.json").read_text()
|
||||||
|
)
|
||||||
|
assert cline_data["mcpServers"]["artemis"]["disabled"] is False
|
||||||
|
|
||||||
|
roo_data = json.loads((tmp_path / ".roo" / "mcp.json").read_text())
|
||||||
|
assert roo_data["mcpServers"]["artemis"]["disabled"] is False
|
||||||
|
|
||||||
# Verify global rule files installed across IDEs
|
# Verify global rule files installed across IDEs
|
||||||
assert (tmp_path / ".gemini" / "GEMINI.md").exists()
|
assert (tmp_path / ".gemini" / "GEMINI.md").exists()
|
||||||
assert (tmp_path / ".gemini" / "rules" / "artemis.md").exists()
|
assert (tmp_path / ".gemini" / "rules" / "artemis.md").exists()
|
||||||
@@ -192,6 +262,15 @@ def test_cli_mcp_install_codex_preserves_config_and_is_idempotent(tmp_path, monk
|
|||||||
assert data["model"] == "test-model"
|
assert data["model"] == "test-model"
|
||||||
assert data["mcp_servers"]["existing"]["command"] == "echo"
|
assert data["mcp_servers"]["existing"]["command"] == "echo"
|
||||||
assert data["mcp_servers"]["artemis"]["args"] == ["-m", "mcp_server"]
|
assert data["mcp_servers"]["artemis"]["args"] == ["-m", "mcp_server"]
|
||||||
|
assert data["mcp_servers"]["artemis"]["enabled"] is True
|
||||||
|
assert data["mcp_servers"]["artemis"]["required"] is True
|
||||||
|
assert data["mcp_servers"]["artemis"]["startup_timeout_sec"] == 120
|
||||||
|
assert data["mcp_servers"]["artemis"]["enabled_tools"] == [
|
||||||
|
"mobile_run_task",
|
||||||
|
"mobile_manage_task",
|
||||||
|
"mobile_get_device_state",
|
||||||
|
"mobile_inspect_trace",
|
||||||
|
]
|
||||||
assert data["mcp_servers"]["artemis"]["env"]["PYTHONPATH"] == str(tmp_path)
|
assert data["mcp_servers"]["artemis"]["env"]["PYTHONPATH"] == str(tmp_path)
|
||||||
assert config_text.count("# BEGIN ARTEMIS MCP CONFIG") == 1
|
assert config_text.count("# BEGIN ARTEMIS MCP CONFIG") == 1
|
||||||
|
|
||||||
@@ -231,3 +310,26 @@ def test_cli_mcp_install_jsonc_and_backup(tmp_path, monkeypatch):
|
|||||||
assert "INVALID JSON DATA" in backup_file.read_text(encoding="utf-8")
|
assert "INVALID JSON DATA" in backup_file.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_mcp_install_openclaw_migrates_legacy_plugin_shape(tmp_path, monkeypatch):
|
||||||
|
"""Verify reinstalling OpenClaw replaces the obsolete plugin wrapper with mcp.servers."""
|
||||||
|
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
|
||||||
|
monkeypatch.setattr("mcp_server.utils.env_utils.get_project_root", lambda: str(tmp_path))
|
||||||
|
config_path = tmp_path / ".openclaw" / "openclaw.json"
|
||||||
|
config_path.parent.mkdir(parents=True)
|
||||||
|
config_path.write_text(
|
||||||
|
'{"plugins":{"artemis_mcp":{"enabled":true},"keep":{"enabled":true}}}',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = runner.invoke(app, ["mcp", "--install", "openclaw"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
data = json.loads(config_path.read_text(encoding="utf-8"))
|
||||||
|
assert "artemis_mcp" not in data["plugins"]
|
||||||
|
assert data["plugins"]["keep"]["enabled"] is True
|
||||||
|
assert data["mcp"]["servers"]["artemis"]["enabled"] is True
|
||||||
|
assert data["mcp"]["servers"]["artemis"]["command"]
|
||||||
|
|
||||||
|
|
||||||
@@ -216,12 +216,15 @@ def test_output_config_and_recording():
|
|||||||
assert "test_event" in output_file.read_text(encoding="utf-8")
|
assert "test_event" in output_file.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
def test_runtime_state_and_ipc():
|
def test_runtime_state_and_ipc(tmp_path, monkeypatch):
|
||||||
"""Test IPC port and LS address state helpers."""
|
"""Test IPC port and LS address state helpers."""
|
||||||
|
ipc_state_file = tmp_path / ".artemis_ipc_port"
|
||||||
|
monkeypatch.setattr("artemis.config.runtime.get_ipc_port_file", lambda: ipc_state_file)
|
||||||
# Test IPC port
|
# Test IPC port
|
||||||
write_ipc_port(49152)
|
write_ipc_port(49152)
|
||||||
assert read_ipc_port() == 49152
|
assert read_ipc_port() == 49152
|
||||||
clear_ipc_port()
|
clear_ipc_port()
|
||||||
|
assert read_ipc_port() is None
|
||||||
|
|
||||||
# Test LS address
|
# Test LS address
|
||||||
write_ls_address("localhost:12345")
|
write_ls_address("localhost:12345")
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
from artemis.services import llm
|
||||||
|
|
||||||
|
|
||||||
|
class _RecordingEngine:
|
||||||
|
current_step_id = "step-7"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.trace_kwargs = None
|
||||||
|
self.published = None
|
||||||
|
|
||||||
|
def record_trace(self, **kwargs):
|
||||||
|
self.trace_kwargs = kwargs
|
||||||
|
|
||||||
|
def _publish(self, event_type, payload):
|
||||||
|
self.published = (event_type, payload)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pause_error_is_persisted_and_published(monkeypatch, tmp_path):
|
||||||
|
engine = _RecordingEngine()
|
||||||
|
monkeypatch.setattr(llm, "_CURRENT_DATA_ENGINE", engine)
|
||||||
|
monkeypatch.setattr(llm.settings, "TRACES_PATH", str(tmp_path / "traces"))
|
||||||
|
|
||||||
|
pause_file = llm._handle_llm_pause_and_resume(RuntimeError("503 high demand"))
|
||||||
|
|
||||||
|
assert pause_file.read_text() == "LLM Error: 503 high demand"
|
||||||
|
assert engine.trace_kwargs == {
|
||||||
|
"type": "llm_call",
|
||||||
|
"name": "llm_pause",
|
||||||
|
"payload": {"error": "503 high demand", "pause": True},
|
||||||
|
"step_id": "step-7",
|
||||||
|
"status": "failed",
|
||||||
|
}
|
||||||
|
event_type, payload = engine.published
|
||||||
|
assert event_type == "task_paused"
|
||||||
|
assert payload["error"] == "503 high demand"
|
||||||
|
assert payload["step_id"] == "step-7"
|
||||||
|
assert isinstance(payload["timestamp"], float)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from artemis.interfaces.cli.commands.ui import _showcase_build_required
|
||||||
|
|
||||||
|
|
||||||
|
def test_showcase_build_required_when_source_is_newer(tmp_path):
|
||||||
|
showcase_dir = tmp_path / "showcase_ui"
|
||||||
|
source_file = showcase_dir / "src" / "app" / "agent.service.ts"
|
||||||
|
built_index = showcase_dir / "dist" / "frontend" / "browser" / "index.html"
|
||||||
|
source_file.parent.mkdir(parents=True)
|
||||||
|
built_index.parent.mkdir(parents=True)
|
||||||
|
source_file.write_text("source", encoding="utf-8")
|
||||||
|
built_index.write_text("build", encoding="utf-8")
|
||||||
|
|
||||||
|
os.utime(built_index, (100, 100))
|
||||||
|
os.utime(source_file, (200, 200))
|
||||||
|
|
||||||
|
assert _showcase_build_required(showcase_dir) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_showcase_build_not_required_when_build_is_current(tmp_path):
|
||||||
|
showcase_dir = tmp_path / "showcase_ui"
|
||||||
|
source_file = showcase_dir / "src" / "app" / "agent.service.ts"
|
||||||
|
built_index = showcase_dir / "dist" / "frontend" / "browser" / "index.html"
|
||||||
|
source_file.parent.mkdir(parents=True)
|
||||||
|
built_index.parent.mkdir(parents=True)
|
||||||
|
source_file.write_text("source", encoding="utf-8")
|
||||||
|
built_index.write_text("build", encoding="utf-8")
|
||||||
|
|
||||||
|
os.utime(source_file, (100, 100))
|
||||||
|
os.utime(built_index, (200, 200))
|
||||||
|
|
||||||
|
assert _showcase_build_required(showcase_dir) is False
|
||||||
Reference in new issue
Block a user