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