Stabilize task execution and UI lifecycle

This commit is contained in:
somew1nd committed 2026-08-19 00:47:45 -07:00
1 parent c78c201e94
commit 4927c4f0b9
35 files changed
+6680 -239

No files matched your search

+32
View File
@@ -28,7 +28,9 @@ class ServerState:
def __init__(self):
self.ipc_subscribers: list[Callable[[str, Any], None]] = []
self.ipc_server: asyncio.Server | None = None
self.ipc_serve_task: asyncio.Task | None = None
self.ipc_port: int | None = None
self.is_shutting_down: bool = False
self.current_process: asyncio.subprocess.Process | None = None
self.current_goal: str | None = None
@@ -41,6 +43,7 @@ class ServerState:
# Unified single source of truth for task queue
self.queue_items: list[dict[str, Any]] = []
self._wake_event: asyncio.Event | None = None
self._shutdown_event: asyncio.Event | None = None
self.worker_task: asyncio.Task | None = None
@property
@@ -58,6 +61,22 @@ class ServerState:
self._wake_event = asyncio.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
def task_queue(self) -> list[dict[str, Any]]:
"""Backward compatibility alias for queue_items."""
@@ -131,6 +150,19 @@ class ServerState:
def is_paused(self) -> bool:
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]):
if callback not in self.ipc_subscribers:
self.ipc_subscribers.append(callback)
@@ -206,6 +206,48 @@ class StepRepository:
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]]:
with db_session(self.db_path) as conn:
cursor = conn.cursor()
@@ -249,17 +291,18 @@ class StepRepository:
" t1.payload, t2.name as agent_name FROM traces t1 LEFT"
" 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 ="
" '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",
(step_id,),
)
trace_rows = cursor.fetchall()
generic_tools = []
for tr in trace_rows:
trace_dict = dict(tr)
trace_dict["payload"] = self._clean_tool_payload(
trace_dict.get("payload"), trace_dict.get("type")
)
trace_dict = self._normalize_display_trace(dict(tr))
generic_tools.append(trace_dict)
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"
" 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.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",
(session_id,),
)
@@ -330,10 +377,7 @@ class StepRepository:
sorted_steps = sorted(steps, key=lambda s: s.get("timestamp") or 0)
for tr in stepless_rows:
trace_dict = dict(tr)
trace_dict["payload"] = self._clean_tool_payload(
trace_dict.get("payload"), trace_dict.get("type")
)
trace_dict = self._normalize_display_trace(dict(tr))
tr_time = trace_dict.get("timestamp") or 0
target_step = None
+46 -7
View File
@@ -13,6 +13,7 @@
# limitations under the License.
import asyncio
from contextlib import suppress
import json
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
@@ -148,6 +149,7 @@ async def get_status():
is_paused = state.is_paused
return {
"status": "paused" if is_paused else "running",
"paused_error": state.paused_error if is_paused else None,
"goal": running_goal,
"pid": state.current_process.pid if state.current_process else None,
"session_id": running_sid,
@@ -163,6 +165,7 @@ async def get_status():
conn_model_info = model_service.get_active_model_info(conn_profile)
return {
"status": "paused" if is_paused else "running",
"paused_error": state.paused_error if is_paused else None,
"goal": conn_info.get("goal"),
"pid": conn_info.get("pid"),
"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 event_generator():
queue = asyncio.Queue()
event_loop = asyncio.get_running_loop()
def callback(event_type, data):
try:
@@ -210,22 +214,57 @@ async def stream_events(session_id: str, client: str | None = None):
return
sanitized_data = ipc_service.sanitize_event_data(event_type, data)
loop = asyncio.get_running_loop()
loop.call_soon_threadsafe(queue.put_nowait, (event_type, sanitized_data))
event_loop.call_soon_threadsafe(queue.put_nowait, (event_type, sanitized_data))
except RuntimeError:
pass
state.add_subscriber(callback)
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:
while True:
try:
event_type, data = await asyncio.wait_for(queue.get(), timeout=5.0)
while not state.is_shutting_down:
queue_waiter = asyncio.create_task(queue.get())
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"
except TimeoutError:
else:
queue_waiter.cancel()
with suppress(asyncio.CancelledError):
await queue_waiter
queue_waiter = None
yield "event: keep-alive\ndata: {}\n\n"
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)
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",
},
)
+115 -3
View File
@@ -20,7 +20,9 @@ Modular entrypoint for full trace inspection, step replay, and task execution ma
import asyncio
import os
from pathlib import Path
import signal
import sys
from types import FrameType
# Bootstrap sys.path to allow running from any CWD
_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.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, HTMLResponse
import uvicorn
from artemis.runtime import DeviceExecutionLock
try:
from admin_console.core.config import (
@@ -97,6 +102,11 @@ app.add_middleware(
@app.on_event("startup")
async def on_startup():
"""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.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())
@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
app.include_router(stream.router)
app.include_router(media.router)
@@ -278,11 +321,80 @@ task_queue = state.task_queue
queue_goals = state.queue_goals
def main():
import uvicorn
class ArtemisUvicornServer(uvicorn.Server):
"""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))
uvicorn.run(app, host="0.0.0.0", port=port)
run_ui_server(host="0.0.0.0", port=port)
if __name__ == "__main__":
+93 -59
View File
@@ -13,6 +13,7 @@
# limitations under the License.
import asyncio
from contextlib import suppress
import json
from typing import Any
@@ -134,73 +135,77 @@ class IPCService:
async def start_server(cls):
async def handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
current_session_id = None
while True:
line = await reader.readline()
if not line:
break
try:
payload = json.loads(line.decode("utf-8"))
event_type = payload.get("event_type")
data = payload.get("data")
try:
while not state.is_shutting_down:
line = await reader.readline()
if not line:
break
try:
payload = json.loads(line.decode("utf-8"))
event_type = payload.get("event_type")
data = payload.get("data")
if event_type == "session_started" and data:
incoming_sid = data.get("session_id")
if incoming_sid and str(incoming_sid) in getattr(
state, "cancelled_session_ids", set()
):
if event_type == "session_started" and data:
incoming_sid = data.get("session_id")
if incoming_sid and str(incoming_sid) in getattr(
state, "cancelled_session_ids", set()
):
print(
f"[IPC] Ignoring session_started for cancelled session: {incoming_sid}"
)
continue
state.active_session_id = incoming_sid
current_session_id = incoming_sid
pid = data.get("pid")
goal = data.get("initial_goal")
device_info = data.get("device_info")
if isinstance(device_info, dict) and device_info.get("profile"):
state.current_profile = device_info.get("profile")
if goal:
state.current_goal = goal
print(
f"[IPC] Ignoring session_started for cancelled session: {incoming_sid}"
f"[IPC] Session started: {state.active_session_id} (profile: {state.current_profile})"
)
continue
if current_session_id:
state.active_connections[str(current_session_id)] = {
"writer": writer,
"pid": pid,
"goal": goal,
"profile": state.current_profile,
}
elif event_type == "session_ended" and data:
ended_sid = data.get("session_id")
if state.active_session_id and str(state.active_session_id) == str(
ended_sid
):
state.active_session_id = None
state.current_goal = None
state.current_profile = None
state.active_session_id = incoming_sid
current_session_id = incoming_sid
pid = data.get("pid")
goal = data.get("initial_goal")
device_info = data.get("device_info")
if isinstance(device_info, dict) and device_info.get("profile"):
state.current_profile = device_info.get("profile")
if goal:
state.current_goal = goal
print(
f"[IPC] Session started: {state.active_session_id} (profile: {state.current_profile})"
)
if current_session_id:
state.active_connections[str(current_session_id)] = {
"writer": writer,
"pid": pid,
"goal": goal,
"profile": state.current_profile,
}
elif event_type == "session_ended" and data:
ended_sid = data.get("session_id")
if state.active_session_id and str(state.active_session_id) == str(
ended_sid
):
state.active_session_id = None
state.current_goal = None
state.current_profile = None
for cb in list(state.ipc_subscribers):
try:
cb(event_type, data)
except Exception:
pass
except Exception as e:
print(f"IPC parse error: {e}")
if current_session_id and str(current_session_id) in state.active_connections:
try:
del state.active_connections[str(current_session_id)]
except KeyError:
pass
writer.close()
await writer.wait_closed()
for cb in list(state.ipc_subscribers):
try:
cb(event_type, data)
except Exception:
pass
except (ValueError, UnicodeDecodeError, TypeError) as e:
print(f"IPC parse error: {e}")
except asyncio.CancelledError:
raise
except (ConnectionResetError, BrokenPipeError, OSError):
# Normal when a task or the UI server is shutting down on Windows.
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()
with suppress(ConnectionResetError, BrokenPipeError, OSError):
await writer.wait_closed()
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_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}")
try:
from artemis.config import write_ipc_port
@@ -209,5 +214,34 @@ class IPCService:
except Exception as 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()
@@ -16,6 +16,7 @@ import asyncio
from datetime import datetime
import os
import shutil
import subprocess
import sys
import time
from typing import Any
@@ -76,6 +77,31 @@ class TaskQueueService:
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
def ensure_worker_running(cls):
"""Guarantees that the background queue worker task is active and running."""
@@ -182,6 +208,7 @@ class TaskQueueService:
*cmd,
cwd=str(WORKSPACE_ROOT),
env=env,
**cls._subprocess_creation_kwargs(),
)
state.current_process = proc
task_item["pid"] = proc.pid
@@ -193,10 +220,7 @@ class TaskQueueService:
print(
f"[QueueWorker] Task [{sess_id}] was cancelled during launch. Terminating."
)
try:
proc.kill()
except Exception:
pass
await cls._terminate_worker_process(proc)
# 3. Await subprocess completion
returncode = await proc.wait()
@@ -225,10 +249,7 @@ class TaskQueueService:
except asyncio.CancelledError:
print("[QueueWorker] Task received cancellation signal.")
if state.current_process and state.current_process.returncode is None:
try:
state.current_process.kill()
except Exception:
pass
await cls._terminate_worker_process(state.current_process)
break
except Exception as e:
print(f"[QueueWorker] Unexpected error executing task: {e}")
@@ -69,8 +69,8 @@
</span>
<div class="item-header-actions">
<span class="item-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
<button
*ngIf="getTaskStatus(session) !== 'running'"
<button
*ngIf="getTaskStatus(session) !== 'running' && getTaskStatus(session) !== 'paused'"
type="button"
class="btn-delete-card"
(click)="deleteTask(session.session_id, $event)"
@@ -114,8 +114,8 @@
</span>
<div class="item-header-actions">
<span class="item-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
<button
*ngIf="getTaskStatus(session) !== 'running'"
<button
*ngIf="getTaskStatus(session) !== 'running' && getTaskStatus(session) !== 'paused'"
type="button"
class="btn-delete-card"
(click)="deleteTask(session.session_id, $event)"
@@ -285,7 +285,6 @@
<!-- Event Logs Section -->
<main class="stream-logs-content" (scroll)="onStreamScroll($event)">
<div *ngIf="consolidatedBlocks().length === 0 && !showPlanningLoader()" class="empty-state">
<div class="empty-icon"><span class="material-symbols-outlined">sensors</span></div>
<h3>No Stream Activity Selected</h3>
@@ -516,13 +515,30 @@
<ng-container *ngIf="item.type === 'tool'">
<!-- 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="action-card-header">
<div class="action-card-header llm-failure-header">
<span class="material-symbols-outlined action-icon is-failed">error</span>
<span class="action-title">LLM Request Failed</span>
<span class="action-status-badge is-failed">
<span class="material-symbols-outlined status-icon">warning</span>
Failed
<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="material-symbols-outlined status-icon">warning</span>
Failed
</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 class="action-card-body">
<div class="action-detail-row">
@@ -610,6 +610,12 @@ $uniform-gap: 8px;
animation: pulse-black-dot-small 1.5s infinite ease-in-out;
}
}
&.paused {
background: #fff7ed;
color: #c2410c;
border-color: #fdba74;
&::before { background-color: #f97316; }
}
&.completed {
background: #fafafa;
color: #1f1f1f;
@@ -2336,6 +2342,61 @@ $uniform-gap: 8px;
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 {
@@ -168,13 +168,13 @@ export class AgentStreamComponent implements AfterViewInit {
public activeQueue = computed(() => {
const list = this.agentService.sessions().filter((s) => {
const status = this.getTaskStatus(s);
return status === 'running' || status === 'pending';
return status === 'running' || status === 'paused' || status === 'pending';
});
return list.sort((a, b) => {
const statusA = this.getTaskStatus(a);
const statusB = this.getTaskStatus(b);
if (statusA === 'running') return -1;
if (statusB === 'running') return 1;
if (statusA === 'running' || statusA === 'paused') return -1;
if (statusB === 'running' || statusB === 'paused') return 1;
return a.start_time - b.start_time;
});
});
@@ -244,6 +244,28 @@ export class AgentStreamComponent implements AfterViewInit {
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 {
if (!phase || !phase.blocks) return false;
return phase.blocks.some((b: any) => this.hasVisibleContent(b));
@@ -447,13 +469,13 @@ export class AgentStreamComponent implements AfterViewInit {
this.isNotesDropdownOpen.set(false);
}
public getTaskStatus(session: Session): 'running' | 'completed' | 'pending' | 'failed' | 'cancelled' {
if (session.session_id === this.agentService.runningSessionId() && this.agentService.agentStatus() === 'running') {
return 'running';
public getTaskStatus(session: Session): 'running' | 'paused' | 'completed' | 'pending' | 'failed' | 'cancelled' {
if (session.session_id === this.agentService.runningSessionId() && (this.agentService.agentStatus() === 'running' || this.agentService.agentStatus() === 'paused')) {
return this.agentService.agentStatus() as 'running' | 'paused';
}
if (session.status) {
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;
}
}
@@ -819,20 +841,12 @@ export class AgentStreamComponent implements AfterViewInit {
}
public isTerminalLLMFailure(tool: any): boolean {
if (!tool) return false;
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;
}
}
}
}
return false;
return !!tool && this.retryablePauseTrace() === tool;
}
public retryPausedTask(event: Event): void {
event.stopPropagation();
this.agentService.resumeTask();
}
public getLLMErrorText(tool: any): string {
@@ -78,8 +78,8 @@
</span>
<div class="task-card-header-actions">
<span class="task-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
<button
*ngIf="getTaskStatus(session) !== 'running'"
<button
*ngIf="getTaskStatus(session) !== 'running' && getTaskStatus(session) !== 'paused'"
type="button"
class="btn-delete-card"
(click)="deleteTask(session.session_id, $event)"
@@ -127,8 +127,8 @@
</span>
<div class="task-card-header-actions">
<span class="task-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
<button
*ngIf="getTaskStatus(session) !== 'running'"
<button
*ngIf="getTaskStatus(session) !== 'running' && getTaskStatus(session) !== 'paused'"
type="button"
class="btn-delete-card"
(click)="deleteTask(session.session_id, $event)"
@@ -358,6 +358,12 @@ $text-muted: #a1a1aa;
animation: pulse-black-dot-small 1.5s infinite ease-in-out;
}
}
&.paused {
background: #fff7ed;
color: #c2410c;
border-color: #fdba74;
&::before { background-color: #f97316; }
}
&.completed {
background: #fafafa;
color: #1f1f1f;
@@ -46,13 +46,13 @@ export class ChatInterfaceComponent {
public activeQueue = computed(() => {
const list = this.agentService.sessions().filter((s) => {
const status = this.getTaskStatus(s);
return status === 'running' || status === 'pending';
return status === 'running' || status === 'paused' || status === 'pending';
});
return list.sort((a, b) => {
const statusA = this.getTaskStatus(a);
const statusB = this.getTaskStatus(b);
if (statusA === 'running') return -1;
if (statusB === 'running') return 1;
if (statusA === 'running' || statusA === 'paused') return -1;
if (statusB === 'running' || statusB === 'paused') return 1;
return a.start_time - b.start_time; // FIFO order
});
});
@@ -169,13 +169,13 @@ export class ChatInterfaceComponent {
/**
* Determine the current task execution status
*/
public getTaskStatus(session: Session): 'running' | 'completed' | 'pending' | 'failed' | 'cancelled' {
if (session.session_id === this.agentService.runningSessionId() && this.agentService.agentStatus() === 'running') {
return 'running';
public getTaskStatus(session: Session): 'running' | 'paused' | 'completed' | 'pending' | 'failed' | 'cancelled' {
if (session.session_id === this.agentService.runningSessionId() && (this.agentService.agentStatus() === 'running' || this.agentService.agentStatus() === 'paused')) {
return this.agentService.agentStatus() as 'running' | 'paused';
}
if (session.status) {
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;
}
}
@@ -24,7 +24,7 @@ export interface TaskQueueItem {
session_id: string;
goal: string;
profile?: string;
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
status: 'pending' | 'running' | 'paused' | 'completed' | 'failed' | 'cancelled';
created_at?: number;
start_time?: number;
}
@@ -47,9 +47,10 @@ export interface AgentStatusResponse {
queue?: (TaskQueueItem | string)[];
background_tasks?: any[];
model_info?: ModelInfo | null;
paused_error?: string | null;
}
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 {
private http = inject(HttpClient);
private activePauseCardKey: string | null = null;
// Signals to expose state to components
private rawSessions = signal<Session[]>([]);
@@ -43,38 +44,38 @@ export class AgentService {
// 1. First add raw sessions from DB
raw.forEach((s) => {
const isCurrentRunning = status === 'running' && runId === s.session_id;
const isPending = this.pendingQueue().some(p => p.session_id === s.session_id) && !isCurrentRunning;
let sStatus = isCurrentRunning ? 'running' : (isPending ? 'pending' : s.status);
if (sStatus === 'running' && !isCurrentRunning) {
const isCurrentActive = (status === 'running' || status === 'paused') && runId === s.session_id;
const isPending = this.pendingQueue().some(p => p.session_id === s.session_id) && !isCurrentActive;
let sStatus = isCurrentActive ? status : (isPending ? 'pending' : s.status);
if ((sStatus === 'running' || sStatus === 'paused') && !isCurrentActive) {
const activeRun = raw.find(r => r.session_id === runId);
sStatus = (activeRun && (s.start_time || 0) > (activeRun.start_time || 0)) ? 'pending' : 'completed';
}
sessionMap.set(s.session_id, {
...s,
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
pending.forEach((p) => {
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, {
...p,
status: isCurrentRunning ? 'running' : 'pending'
status: isCurrentRunning ? status : 'pending'
});
}
});
// 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, {
session_id: runId,
initial_goal: goal || '',
start_time: Date.now() / 1000,
status: 'running',
status,
model_info: this.activeModel() || undefined
});
}
@@ -127,8 +128,8 @@ export class AgentService {
*/
public isCurrentSessionRunning = computed(() => {
const session = this.currentSession();
if (session) return session.status === 'running';
return this.agentStatus() === 'running';
if (session) return session.status === 'running' || session.status === 'paused';
return this.agentStatus() === 'running' || this.agentStatus() === 'paused';
});
/**
@@ -153,8 +154,8 @@ export class AgentService {
* Check if any task is currently running
*/
public isRunningTask = computed(() => {
if (this.agentStatus() === 'running') return true;
return this.sessions().some(s => s.status === 'running');
if (this.agentStatus() === 'running' || this.agentStatus() === 'paused') return true;
return this.sessions().some(s => s.status === 'running' || s.status === 'paused');
});
/**
@@ -246,6 +247,7 @@ export class AgentService {
next: () => {
this.isPaused.set(false);
this.pausedError.set(null);
this.fetchStatus();
},
error: (err) => {
console.error('Failed to resume task:', err);
@@ -381,6 +383,7 @@ export class AgentService {
this.currentSessionId.set(sessionId);
this.sessionLogs.set([]); // Reset logs for new session selection
this.activePauseCardKey = null;
this.isPaused.set(false);
this.pausedError.set(null);
this.fetchNotes(sessionId);
@@ -411,7 +414,24 @@ export class AgentService {
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) => {
console.error('Failed to pre-load historical steps:', err);
@@ -507,7 +527,14 @@ export class AgentService {
if (eventType === 'task_paused') {
this.isPaused.set(true);
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;
}
@@ -515,6 +542,7 @@ export class AgentService {
this.isPaused.set(false);
this.isRetrying.set(false);
this.pausedError.set(null);
this.activePauseCardKey = null;
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;
/**
@@ -665,7 +750,8 @@ export class AgentService {
const oldRunningSessionId = this.runningSessionId();
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.runningGoal.set(data.goal || null);
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
const pending: Session[] = (data.queue || []).map((item: any, index: number) => {
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
if (data.status === 'running' && data.session_id) {
if (isActive && data.session_id) {
const currentId = this.currentSessionId();
const pinnedId = this.userPinnedSessionId();
if (!currentId) {
+2 -1
View File
@@ -63,7 +63,8 @@ def write_ipc_port(port: int) -> Path:
def clear_ipc_port() -> None:
"""Remove IPC port synchronization state file."""
"""Remove IPC port synchronization state from the process and filesystem."""
os.environ.pop(ENV_ARTEMIS_IPC_PORT, None)
port_file = get_ipc_port_file()
if port_file.exists():
try:
+17 -1
View File
@@ -21,6 +21,7 @@ import os
from pathlib import Path
import shutil
import subprocess
import sys
import threading
import time
from typing import Any
@@ -73,6 +74,21 @@ class EmulatorManager:
self._lock = asyncio.Lock()
self._reader_thread: threading.Thread | None = None
@staticmethod
def _subprocess_creation_kwargs() -> dict[str, Any]:
"""Isolate the emulator from the UI server's console signals.
``start_new_session`` is POSIX-only and is ignored by Python's Windows
subprocess implementation. Windows therefore needs explicit creation
flags so Ctrl+C or shutdown signals sent to the UI server do not also
terminate the emulator. The Linux/macOS behavior remains unchanged.
"""
if sys.platform == "win32":
return {
"creationflags": (subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW)
}
return {"start_new_session": True}
def _locate_emulator(self) -> str | None:
"""Find the emulator binary path from PATH or standard SDK environments."""
resolved = toolchain.resolve("emulator")
@@ -191,7 +207,7 @@ class EmulatorManager:
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
start_new_session=True,
**self._subprocess_creation_kwargs(),
)
self._proc = proc
now = time.time()
+81 -36
View File
@@ -24,7 +24,7 @@ import time
from typing import Any
from uuid import UUID, uuid4
from artemis.config import read_ipc_port, settings
from artemis.config import get_ipc_port_file, read_ipc_port, settings
from artemis.context import ArtemisContext
from artemis.data_engine.models import (
BackgroundTaskRecord,
@@ -82,17 +82,80 @@ class DataEngine:
# Background tasks are written directly to SQLite storage
self.ipc_socket = None
self._ipc_socket_lock = threading.Lock()
self._ipc_shutdown = False
with self._ipc_socket_lock:
self._connect_ipc_locked()
ipc_port = read_ipc_port()
if ipc_port:
@staticmethod
def _ipc_port_candidates() -> list[int]:
"""Return inherited and freshly published UI ports, newest file included.
A worker inherits ``ARTEMIS_IPC_PORT`` when it starts. If the Windows UI
server restarts, that environment value is permanently stale while the
shared port file is updated by the new server.
"""
candidates: list[int] = []
inherited_or_file_port = read_ipc_port()
if inherited_or_file_port:
candidates.append(inherited_or_file_port)
port_file = get_ipc_port_file()
if port_file.exists():
try:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(("127.0.0.1", ipc_port))
s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
self.ipc_socket = s
file_value = port_file.read_text(encoding="utf-8").strip()
if file_value.isdigit() and int(file_value) not in candidates:
candidates.append(int(file_value))
except OSError as exc:
logger.debug(f"Could not read refreshed IPC port from {port_file}: {exc}")
return candidates
def _connect_ipc_locked(self) -> bool:
"""Connect to the UI event bridge while holding ``_ipc_socket_lock``."""
if self._ipc_shutdown:
return False
if self.ipc_socket is not None:
return True
for ipc_port in self._ipc_port_candidates():
try:
sock = socket.create_connection(("127.0.0.1", ipc_port), timeout=1.0)
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
sock.settimeout(None)
self.ipc_socket = sock
logger.info(f"Connected to IPC server on port {ipc_port} with TCP_NODELAY")
except Exception as e:
logger.error(f"Failed to connect to IPC server on port {ipc_port}: {e}")
return True
except OSError as exc:
logger.warning(f"Failed to connect to IPC server on port {ipc_port}: {exc}")
self.ipc_socket = None
return False
def _send_ipc_event(self, event_type: str, data: Any) -> None:
"""Send one complete event, reconnecting once if a stale socket is found.
Publishing can originate from LLM callbacks and background worker threads.
Serializing socket access keeps newline-delimited JSON frames from being
interleaved on Windows and prevents the event that detects a dead socket
from being silently lost.
"""
payload = json.dumps({"event_type": event_type, "data": data}, default=str) + "\n"
encoded_payload = payload.encode("utf-8")
with self._ipc_socket_lock:
for _attempt in range(2):
if not self._connect_ipc_locked():
return
try:
self.ipc_socket.sendall(encoded_payload)
return
except OSError as exc:
logger.warning(f"IPC send failed; reconnecting: {exc}")
try:
self.ipc_socket.close()
except Exception:
pass
self.ipc_socket = None
@property
def base_dir(self) -> Path:
@@ -144,28 +207,7 @@ class DataEngine:
except Exception:
pass
# Auto-reconnect to IPC socket if disconnected or not yet connected
if not getattr(self, "ipc_socket", None):
ipc_port = read_ipc_port()
if ipc_port:
try:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(("127.0.0.1", ipc_port))
s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
self.ipc_socket = s
except Exception:
self.ipc_socket = None
if getattr(self, "ipc_socket", None):
try:
payload = json.dumps({"event_type": event_type, "data": data}, default=str) + "\n"
self.ipc_socket.sendall(payload.encode("utf-8"))
except Exception:
try:
self.ipc_socket.close()
except Exception:
pass
self.ipc_socket = None
self._send_ipc_event(event_type, data)
def start_session(self, goal: str, device_info: dict[str, Any] | None = None) -> UUID:
"""Start a new session."""
@@ -601,11 +643,14 @@ class DataEngine:
if thread.is_alive():
await asyncio.to_thread(thread.join)
if getattr(self, "ipc_socket", None):
try:
self.ipc_socket.close()
except Exception:
pass
with self._ipc_socket_lock:
self._ipc_shutdown = True
if self.ipc_socket is not None:
try:
self.ipc_socket.close()
except Exception:
pass
self.ipc_socket = None
logger.info("DataEngine shutdown complete. All data persisted.")
+172 -29
View File
@@ -34,6 +34,18 @@ import typer
logger = get_logger(__name__)
console = Console()
ARTEMIS_MCP_TOOLS = (
"mobile_run_task",
"mobile_manage_task",
"mobile_get_device_state",
"mobile_inspect_trace",
)
def _eager_tools_config() -> dict[str, dict[str, bool]]:
"""Return the eager-loading extension used by clients that support it."""
return {tool_name: {"eager": True} for tool_name in ARTEMIS_MCP_TOOLS}
def _get_vscode_user_dir() -> Path:
"""Returns the platform-specific VS Code user configuration directory."""
@@ -51,32 +63,94 @@ def _get_config_snippet(client: str, python_exe: str, project_root: str) -> dict
config_body = {
"command": python_exe,
"args": ["-m", "mcp_server"],
"cwd": project_root,
"env": {
"PYTHONUNBUFFERED": "1",
"PYTHONPATH": project_root,
},
}
config_with_cwd = {**config_body, "cwd": project_root}
if client in ("antigravity", "jetski"):
if client == "antigravity":
return {
"mcpServers": {
"artemis": {
**config_body,
"tools": {
"mobile_run_task": {"eager": True},
"mobile_manage_task": {"eager": True},
"mobile_get_device_state": {"eager": True},
"mobile_inspect_trace": {"eager": True},
},
**config_with_cwd,
# This is the documented way to expose every tool. Avoid
# proprietary eager fields in the current config so a
# strict parser can always load the server.
"disabledTools": [],
}
}
}
elif client == "jetski":
return {
"mcpServers": {
"artemis": {
**config_with_cwd,
"disabledTools": [],
# Retain the legacy Jetski eager extension only in its
# legacy config file. Current Antigravity gets the strict,
# documented configuration above as a safe fallback.
"tools": _eager_tools_config(),
}
}
}
elif client == "openclaw":
return {"plugins": {"artemis_mcp": {"enabled": True, "type": "mcp", "server": config_body}}}
return {
"mcp": {
"servers": {
"artemis": {
**config_with_cwd,
"enabled": True,
"connectionTimeoutMs": 120000,
}
}
}
}
elif client == "codex":
return {"mcp_servers": {"artemis": config_body}}
else: # cursor, windsurf, claude, vscode, cline, roo, generic
return {
"mcp_servers": {
"artemis": {
**config_with_cwd,
# Codex has no eager/deferred MCP setting. Requiring the
# server makes it initialize during host startup, while an
# explicit allow-list guarantees all Artemis tools are
# registered. The host may still present schemas through
# tool search to control context size.
"enabled": True,
"required": True,
"startup_timeout_sec": 120,
"enabled_tools": list(ARTEMIS_MCP_TOOLS),
}
}
}
elif client == "vscode":
return {"servers": {"artemis": {**config_with_cwd, "type": "stdio"}}}
elif client in ("claude", "claude_code", "claude_desktop"):
return {"mcpServers": {"artemis": {**config_body, "type": "stdio"}}}
elif client == "cline":
return {
"mcpServers": {
"artemis": {
**config_body,
"disabled": False,
"autoApprove": [],
"timeout": 120,
}
}
}
elif client in ("roo", "roo_code"):
return {
"mcpServers": {
"artemis": {
**config_body,
"disabled": False,
"alwaysAllow": [],
"timeout": 120,
}
}
}
else: # cursor, windsurf, generic
return {"mcpServers": {"artemis": config_body}}
@@ -100,7 +174,13 @@ def _parse_json_lenient(text: str) -> dict:
return {}
def _merge_json_file(file_path: Path, server_name: str, server_config: dict, key_name: str = "mcpServers") -> bool:
def _merge_json_file(
file_path: Path,
server_name: str,
server_config: dict,
key_name: str | tuple[str, ...] = "mcpServers",
remove_paths: tuple[tuple[str, ...], ...] = (),
) -> bool:
"""Merges server configuration into a target JSON file without overwriting existing servers."""
try:
file_path.parent.mkdir(parents=True, exist_ok=True)
@@ -117,10 +197,25 @@ def _merge_json_file(file_path: Path, server_name: str, server_config: dict, key
except Exception:
pass
if key_name not in data or not isinstance(data.get(key_name), dict):
data[key_name] = {}
for remove_path in remove_paths:
parent = data
for part in remove_path[:-1]:
child = parent.get(part)
if not isinstance(child, dict):
parent = {}
break
parent = child
if parent:
parent.pop(remove_path[-1], None)
data[key_name][server_name] = server_config
key_path = (key_name,) if isinstance(key_name, str) else key_name
target = data
for part in key_path:
if not isinstance(target.get(part), dict):
target[part] = {}
target = target[part]
target[server_name] = server_config
file_path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
return True
except Exception as e:
@@ -141,6 +236,15 @@ def _codex_toml_block(server_config: dict) -> str:
f"args = {args}",
f"cwd = {cwd}",
]
for key in ("enabled", "required"):
if key in server_config:
lines.append(f"{key} = {'true' if server_config[key] else 'false'}")
if "startup_timeout_sec" in server_config:
lines.append(f"startup_timeout_sec = {int(server_config['startup_timeout_sec'])}")
if "enabled_tools" in server_config:
lines.append(
f"enabled_tools = {json.dumps(server_config['enabled_tools'], ensure_ascii=False)}"
)
if env:
lines.extend(["", "[mcp_servers.artemis.env]"])
for key, value in env.items():
@@ -380,12 +484,22 @@ def install_mcp_config(client: str, python_exe: str, project_root: str) -> list[
for target in targets:
snippet = _get_config_snippet(target, python_exe, project_root)
if target in ("antigravity", "jetski"):
server_cfg = snippet["mcpServers"]["artemis"]
current_server_cfg = _get_config_snippet("antigravity", python_exe, project_root)[
"mcpServers"
]["artemis"]
legacy_server_cfg = _get_config_snippet("jetski", python_exe, project_root)[
"mcpServers"
]["artemis"]
jetski_path = Path.home() / ".gemini" / "jetski" / "mcp_config.json"
antigravity_legacy_path = (
Path.home() / ".gemini" / "antigravity" / "mcp_config.json"
)
config_path = Path.home() / ".gemini" / "config" / "mcp_config.json"
if _merge_json_file(jetski_path, "artemis", server_cfg):
if _merge_json_file(jetski_path, "artemis", legacy_server_cfg):
installed_paths.append(str(jetski_path))
if _merge_json_file(config_path, "artemis", server_cfg):
if _merge_json_file(antigravity_legacy_path, "artemis", current_server_cfg):
installed_paths.append(str(antigravity_legacy_path))
if _merge_json_file(config_path, "artemis", current_server_cfg):
installed_paths.append(str(config_path))
elif target in ("claude", "claude_code", "claude_desktop"):
server_cfg = snippet["mcpServers"]["artemis"]
@@ -414,24 +528,53 @@ def install_mcp_config(client: str, python_exe: str, project_root: str) -> list[
if _merge_json_file(windsurf_path, "artemis", server_cfg):
installed_paths.append(str(windsurf_path))
elif target == "vscode":
server_cfg = snippet["mcpServers"]["artemis"]
server_cfg = snippet["servers"]["artemis"]
vscode_path = _get_vscode_user_dir() / "mcp.json"
if _merge_json_file(vscode_path, "artemis", server_cfg):
copilot_path = Path.home() / ".copilot" / "mcp-config.json"
if _merge_json_file(vscode_path, "artemis", server_cfg, key_name="servers"):
installed_paths.append(str(vscode_path))
if _merge_json_file(copilot_path, "artemis", server_cfg, key_name="servers"):
installed_paths.append(str(copilot_path))
elif target == "cline":
server_cfg = snippet["mcpServers"]["artemis"]
cline_path = _get_vscode_user_dir() / "globalStorage" / "saoudrizwan.claude-dev" / "settings" / "cline_mcp_settings.json"
if _merge_json_file(cline_path, "artemis", server_cfg):
installed_paths.append(str(cline_path))
cline_paths = (
Path.home() / ".cline" / "data" / "settings" / "cline_mcp_settings.json",
Path.home() / ".cline" / "mcp.json",
_get_vscode_user_dir()
/ "globalStorage"
/ "saoudrizwan.claude-dev"
/ "settings"
/ "cline_mcp_settings.json",
)
for cline_path in cline_paths:
if _merge_json_file(cline_path, "artemis", server_cfg):
installed_paths.append(str(cline_path))
elif target in ("roo", "roo_code"):
server_cfg = snippet["mcpServers"]["artemis"]
roo_path = _get_vscode_user_dir() / "globalStorage" / "rooveterinaryinc.roo-cline" / "settings" / "cline_mcp_settings.json"
if _merge_json_file(roo_path, "artemis", server_cfg):
installed_paths.append(str(roo_path))
roo_settings_dir = (
_get_vscode_user_dir()
/ "globalStorage"
/ "rooveterinaryinc.roo-cline"
/ "settings"
)
roo_paths = (
roo_settings_dir / "mcp_settings.json",
roo_settings_dir / "cline_mcp_settings.json",
Path(project_root) / ".roo" / "mcp.json",
)
for roo_path in roo_paths:
if _merge_json_file(roo_path, "artemis", server_cfg):
installed_paths.append(str(roo_path))
elif target == "openclaw":
plugin_cfg = snippet["plugins"]["artemis_mcp"]
server_cfg = snippet["mcp"]["servers"]["artemis"]
openclaw_path = Path.home() / ".openclaw" / "openclaw.json"
if _merge_json_file(openclaw_path, "artemis_mcp", plugin_cfg, key_name="plugins"):
if _merge_json_file(
openclaw_path,
"artemis",
server_cfg,
key_name=("mcp", "servers"),
remove_paths=(("plugins", "artemis_mcp"),),
):
installed_paths.append(str(openclaw_path))
elif target == "codex":
server_cfg = snippet["mcp_servers"]["artemis"]
+33 -14
View File
@@ -14,6 +14,8 @@
"""UI Launch and Web Console CLI Command (artemis ui)."""
from pathlib import Path
import sys
import threading
import time
from typing import Annotated
@@ -25,6 +27,27 @@ from rich.panel import Panel
import typer
def _showcase_build_required(showcase_dir: Path) -> bool:
"""Return whether the compiled Angular app is missing or older than its inputs."""
base_dist = showcase_dir / "dist"
candidates = [
base_dist / "frontend" / "browser" / "index.html",
base_dist / "browser" / "index.html",
base_dist / "frontend" / "index.html",
base_dist / "index.html",
]
built_indexes = [path for path in candidates if path.exists()]
if not built_indexes:
return True
build_time = max(path.stat().st_mtime for path in built_indexes)
input_paths = [showcase_dir / "package.json", showcase_dir / "angular.json"]
source_dir = showcase_dir / "src"
if source_dir.exists():
input_paths.extend(path for path in source_dir.rglob("*") if path.is_file())
return any(path.exists() and path.stat().st_mtime > build_time for path in input_paths)
def _poll_and_open_browser(url: str, stop_event: threading.Event, timeout: float = 15.0) -> None:
"""Poll the UI server until it responds, then launch the user's default browser."""
start = time.time()
@@ -74,7 +97,11 @@ def ui_command(
f"📱 [bold green]Showcase UI & Onboarding:[/bold green] [cyan]{local_url}[/cyan]\n"
f"🛠️ [bold magenta]Admin Debug Console:[/bold magenta] [cyan]{local_url}/admin[/cyan]\n"
f"📖 [dim]API Documentation:[/dim] [dim]{local_url}/docs[/dim]\n\n"
f"[dim]Press Ctrl+C to stop the server.[/dim]"
+ (
"[dim]Press Ctrl+C to stop while idle; Ctrl+Break forces shutdown during a task.[/dim]"
if sys.platform == "win32"
else "[dim]Press Ctrl+C to stop the server.[/dim]"
)
)
console.print(Panel(msg, title="🚀 Web Server Active", border_style="cyan", expand=False))
# Ensure .env exists in workspace root
@@ -94,22 +121,15 @@ def ui_command(
except Exception:
pass
base_dist = ROOT_DIR / "apps" / "showcase_ui" / "dist"
candidates = [
base_dist / "frontend" / "browser" / "index.html",
base_dist / "browser" / "index.html",
base_dist / "frontend" / "index.html",
base_dist / "index.html",
]
if not any(p.exists() for p in candidates):
showcase_dir = ROOT_DIR / "apps" / "showcase_ui"
if _showcase_build_required(showcase_dir):
import shutil
import subprocess
if shutil.which("npm"):
console.print(
" [yellow]🎨 Showcase UI build not found. Compiling Angular Showcase UI...[/yellow]"
" [yellow]🎨 Showcase UI sources changed. Compiling Angular Showcase UI...[/yellow]"
)
showcase_dir = ROOT_DIR / "apps" / "showcase_ui"
try:
subprocess.run(["npm", "install", "--silent"], cwd=showcase_dir, check=True)
subprocess.run(["npm", "run", "build"], cwd=showcase_dir, check=True)
@@ -127,10 +147,9 @@ def ui_command(
t.start()
try:
import uvicorn
from apps.admin_console.server import app
from apps.admin_console.server import run_ui_server
uvicorn.run(app, host=host, port=port, reload=reload)
run_ui_server(host=host, port=port, reload=reload)
except KeyboardInterrupt:
console.print("\n[yellow]🛑 UI Server stopped.[/yellow]")
finally:
+3
View File
@@ -14,9 +14,12 @@
"""Artemis cross-platform runtime and process supervisor subsystem."""
from artemis.runtime.device_lock import DeviceBusyError, DeviceExecutionLock
from artemis.runtime.supervisor import ProcessSupervisor, process_supervisor
__all__ = [
"DeviceBusyError",
"DeviceExecutionLock",
"ProcessSupervisor",
"process_supervisor",
]
+189
View File
@@ -0,0 +1,189 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Cross-process exclusive ownership for a local mobile device."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
import hashlib
import json
import os
from pathlib import Path
import time
import uuid
from artemis.config.paths import get_temp_dir
class DeviceBusyError(RuntimeError):
"""Raised when another Artemis process already owns a device."""
@dataclass(frozen=True)
class DeviceLockOwner:
pid: int
process_created_at: float
token: str
device_id: str
description: str
acquired_at: str
@classmethod
def from_dict(cls, value: dict) -> DeviceLockOwner:
return cls(
pid=int(value["pid"]),
process_created_at=float(value["process_created_at"]),
token=str(value["token"]),
device_id=str(value["device_id"]),
description=str(value.get("description", "unknown task")),
acquired_at=str(value.get("acquired_at", "unknown time")),
)
class DeviceExecutionLock:
"""A PID-aware lock shared by UI, CLI, and MCP task processes.
Atomic file creation provides the inter-process exclusion. PID creation
time prevents a stale lock from blocking a device after a process exits or
after the operating system reuses the same PID.
"""
_MALFORMED_LOCK_GRACE_SECONDS = 5.0
def __init__(self, device_id: str, description: str = "Artemis task"):
self.device_id = device_id
self.description = description
self.token = uuid.uuid4().hex
digest = hashlib.sha256(device_id.encode("utf-8", errors="replace")).hexdigest()[:20]
self.path = get_temp_dir("device-locks") / f"device-{digest}.lock"
self._acquired = False
@staticmethod
def _current_process_created_at() -> float:
try:
import psutil
return float(psutil.Process(os.getpid()).create_time())
except Exception:
return 0.0
@staticmethod
def _owner_is_alive(owner: DeviceLockOwner) -> bool:
try:
import psutil
process = psutil.Process(owner.pid)
if not process.is_running():
return False
if owner.process_created_at <= 0:
return True
return abs(process.create_time() - owner.process_created_at) < 1.0
except Exception:
return False
@staticmethod
def _read_owner(path: Path) -> DeviceLockOwner | None:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
return DeviceLockOwner.from_dict(payload)
except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError):
return None
def _remove_stale_lock(self) -> bool:
owner = self._read_owner(self.path)
if owner is not None:
if self._owner_is_alive(owner):
return False
else:
try:
if time.time() - self.path.stat().st_mtime < self._MALFORMED_LOCK_GRACE_SECONDS:
return False
except OSError:
return True
try:
self.path.unlink(missing_ok=True)
return True
except OSError:
return False
def acquire(self) -> None:
owner_payload = {
"pid": os.getpid(),
"process_created_at": self._current_process_created_at(),
"token": self.token,
"device_id": self.device_id,
"description": self.description,
"acquired_at": datetime.now(UTC).isoformat(),
}
for _ in range(4):
try:
fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
except FileExistsError:
if self._remove_stale_lock():
continue
owner = self._read_owner(self.path)
owner_text = (
f"PID {owner.pid} ({owner.description}, since {owner.acquired_at})"
if owner is not None
else "another process"
)
raise DeviceBusyError(
f"Device '{self.device_id}' is already controlled by {owner_text}. "
"Wait for that task to finish or stop it before starting another task."
)
else:
try:
os.write(fd, json.dumps(owner_payload, ensure_ascii=False).encode("utf-8"))
finally:
os.close(fd)
self._acquired = True
return
raise DeviceBusyError(f"Could not acquire the execution lock for '{self.device_id}'.")
def release(self) -> None:
if not self._acquired:
return
try:
owner = self._read_owner(self.path)
if owner is not None and owner.token == self.token:
self.path.unlink(missing_ok=True)
finally:
self._acquired = False
@classmethod
def cleanup_stale_locks(cls) -> int:
"""Remove lock files whose owning process is no longer alive."""
removed = 0
lock_dir = get_temp_dir("device-locks")
for path in lock_dir.glob("device-*.lock"):
owner = cls._read_owner(path)
if owner is not None and cls._owner_is_alive(owner):
continue
if owner is None:
try:
if time.time() - path.stat().st_mtime < cls._MALFORMED_LOCK_GRACE_SECONDS:
continue
except OSError:
continue
try:
path.unlink(missing_ok=True)
removed += 1
except OSError:
pass
return removed
+12 -1
View File
@@ -53,6 +53,7 @@ from artemis.controllers.controller_factory import get_controller
from artemis.controllers.platform_specific_commands_controller import (
get_first_device,
)
from artemis.runtime import DeviceExecutionLock
from artemis.data_engine.engine import DataEngine
from artemis.data_engine.trace import DataEngineCallbackHandler
from artemis.graph.graph import get_graph
@@ -469,7 +470,13 @@ class Agent:
last_state: State | None = None
last_state_snapshot: dict | None = None
output = None
device_lock = DeviceExecutionLock(
self._device_context.device_id,
description=f"automation task: {request.goal[:120]}",
)
try:
if os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
await asyncio.to_thread(device_lock.acquire)
async with context:
recording_started = False
if self._config.video_recording_tools_enabled:
@@ -634,7 +641,11 @@ class Agent:
raise
finally:
await self._finalize_tracing(task=task, context=context)
try:
await self._finalize_tracing(task=task, context=context)
finally:
if os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
await asyncio.to_thread(device_lock.release)
async with self._task_lock:
if self._current_task and not self._current_task.done():
+24 -1
View File
@@ -25,6 +25,7 @@ import functools
import logging
from pathlib import Path
import random
import time
from typing import Any, Literal, TypeVar, overload
from uuid import uuid4
@@ -67,7 +68,29 @@ def _handle_llm_pause_and_resume(last_error: Exception) -> Path:
pass
if _CURRENT_DATA_ENGINE:
_CURRENT_DATA_ENGINE._publish("task_paused", {"error": err_msg})
step_id = getattr(_CURRENT_DATA_ENGINE, "current_step_id", None)
timestamp = time.time()
try:
# Persist exhausted LLM retries as a failed call so the same error
# card is available both in the live stream and after a refresh.
_CURRENT_DATA_ENGINE.record_trace(
type="llm_call",
name="llm_pause",
payload={"error": err_msg, "pause": True},
step_id=step_id,
status="failed",
)
except Exception as trace_error:
llm_logger.warning("Failed to persist paused LLM error trace: %s", trace_error)
_CURRENT_DATA_ENGINE._publish(
"task_paused",
{
"error": err_msg,
"step_id": str(step_id) if step_id else None,
"timestamp": timestamp,
},
)
return pause_file
+13 -1
View File
@@ -25,6 +25,7 @@ from typing import Any
from mcp_server.base import mcp
from mcp_server.notifiers import notify
from mcp_server.utils import env_utils, trace_store
from artemis.runtime import DeviceExecutionLock, process_supervisor
@mcp.tool()
@@ -256,7 +257,18 @@ def mobile_manage_task(
}
try:
os.kill(pid, signal.SIGTERM)
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)
trace_store.update_trace_status(
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."""
import subprocess
import sys
import pytest
from httpx import ASGITransport, AsyncClient
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
@@ -13,6 +13,8 @@
# limitations under the License.
import asyncio
import importlib
import sys
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@@ -21,8 +23,13 @@ from apps.admin_console.services.task_queue_service import TaskQueueService, tas
@pytest.fixture(autouse=True)
def clean_state():
def clean_state(tmp_path, monkeypatch):
"""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.queue_items.clear()
state.current_process = None
@@ -41,6 +48,15 @@ def clean_state():
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
async def test_get_next_pending_task():
state.queue_items = [
@@ -145,9 +161,11 @@ async def test_queue_worker_execution_lifecycle():
@pytest.mark.asyncio
async def test_queue_worker_cmd_construction():
executed_cmds = []
executed_kwargs = []
async def fake_subprocess_exec(*args, **kwargs):
executed_cmds.append(list(args))
executed_kwargs.append(kwargs)
proc = MagicMock()
proc.pid = 88888
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 "--app-path" 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
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"])
for _ in range(40):
if len(executed_goals) == 2:
if len(executed_goals) == 2 and len(state.queue_items) == 0:
break
await asyncio.sleep(0.05)
+85 -1
View File
@@ -12,11 +12,95 @@
# See the License for the specific language governing permissions and
# 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.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):
# Setup mock context
mock_ctx = MagicMock(spec=ArtemisContext)
+110
View File
@@ -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
View File
@@ -81,12 +81,28 @@ def test_cli_mcp_generate_config():
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"])
assert result.exit_code == 0
assert "mcpServers" in result.output
assert "mobile_run_task" in result.output
assert "eager" in result.output
server_config = _get_config_snippet("antigravity", "python", "/project")["mcpServers"][
"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():
@@ -104,11 +120,26 @@ def test_cli_mcp_generate_config_all():
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"])
assert result.exit_code == 0
assert "[mcp_servers.artemis]" 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):
@@ -126,6 +157,11 @@ def test_cli_mcp_install_antigravity(tmp_path, monkeypatch):
assert "mobile_run_task" in data["mcpServers"]["artemis"]["tools"]
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
gemini_md = tmp_path / ".gemini" / "GEMINI.md"
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):
"""Verify 'artemis mcp --install all' installs configs and global rules to all supported IDE locations."""
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))
result = runner.invoke(app, ["mcp", "--install", "all"])
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 / ".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
assert (tmp_path / ".gemini" / "GEMINI.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["mcp_servers"]["existing"]["command"] == "echo"
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 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")
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"]
+4 -1
View File
@@ -216,12 +216,15 @@ def test_output_config_and_recording():
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."""
ipc_state_file = tmp_path / ".artemis_ipc_port"
monkeypatch.setattr("artemis.config.runtime.get_ipc_port_file", lambda: ipc_state_file)
# Test IPC port
write_ipc_port(49152)
assert read_ipc_port() == 49152
clear_ipc_port()
assert read_ipc_port() is None
# Test LS address
write_ls_address("localhost:12345")
+37
View File
@@ -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)
+41
View File
@@ -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
Generated
+4895
View File
File diff suppressed because it is too large. Load diff