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
+6611 -170

No files matched your search

+32
View File
@@ -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
+46 -7
View File
@@ -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",
},
)
+115 -3
View File
@@ -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__":
+42 -8
View File
@@ -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) {
+2 -1
View File
@@ -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:
+17 -1
View File
@@ -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()
+77 -32
View File
@@ -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.")
+168 -25
View File
@@ -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"]
+33 -14
View File
@@ -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:
+3
View File
@@ -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",
] ]
+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
+11
View File
@@ -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
View File
@@ -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
+12
View 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)
+85 -1
View File
@@ -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)
+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(): 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"]
+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") 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")
+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