feat: enhance daemon dispatching, device readiness fallback, and live streaming UI

- Admin Console & Task Queue: add cancellation, session startup progress endpoints, and support ingress metadata
- Diagnostics & Runtime: enhance ADB probe to prefer unlocked devices and synchronize with readiness engine
- MCP Server: dispatch tasks through Artemis Daemon scheduler with fallback to standalone runner, improve action overlays
- Showcase UI: improve session auto-selection with pinning support, render startup progress, and refine stream styling
- CLI & Makefile: expose server lifecycle commands (restart, stop, status)
- Configuration: ignore runtime server state file in .gitignore
This commit is contained in:
somew1nd committed 2026-08-26 17:41:23 -07:00
1 parent 57655266cc
commit 8fdecf23d7
33 files changed
+1315 -293

No files matched your search

+1
View File
@@ -87,6 +87,7 @@ yarn-error.log*
traces/
scratch/
.artemis_paused
.artemis_server.json
*.log
data_engine.db
data_engine.db-journal
+10 -1
View File
@@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
.PHONY: help test install install-deps setup start ui build-ui doctor clean precommit-install precommit lint format typecheck
.PHONY: help test install install-deps setup start ui restart stop status build-ui doctor clean precommit-install precommit lint format typecheck
help: ## Show this help message
@echo 'Usage: make [target]'
@@ -26,6 +26,15 @@ start: ## One-click start Artemis Showcase UI and auto-open browser
ui: ## Launch the unified Showcase UI & Admin Console in browser
@uv run artemis ui --open
restart: ## Restart running Artemis Web UI & server
@uv run artemis restart
stop: ## Stop running Artemis Web UI & server
@uv run artemis stop
status: ## Display Artemis Web UI & server status
@uv run artemis status
build-ui: ## Build the Showcase UI Angular frontend
@echo "🎨 Building Showcase UI..."
@cd apps/showcase_ui && npm install && npm run build
+8
View File
@@ -161,6 +161,14 @@ async def get_session_background_tasks(session_id: str):
return []
@router.get("/api/sessions/{session_id}/startup_progress")
async def get_session_startup_progress(session_id: str):
try:
return state.get_startup_progress(session_id)
except Exception:
return []
@router.post("/api/cleanup")
async def cleanup_history_endpoint():
try:
+93 -10
View File
@@ -16,7 +16,7 @@ import asyncio
from contextlib import suppress
import json
from typing import Any
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
from artemis.core.diagnostics import readiness_engine
from artemis.runtime import DeviceExecutionLock, device_pool
@@ -84,15 +84,30 @@ async def run_task(request: RunRequest):
# polling intervals cannot start through a stale Ready state. Use the
# bounded submission probe: the full diagnostics path also scans packages,
# emulator installations, Android version, and screen size.
device_probe = await readiness_engine.run_device_submission_probe()
target_serial = request.device_serial or readiness_engine.get_active_device_serial()
device_probe = await readiness_engine.run_device_submission_probe(
target_serial=target_serial
)
if device_probe and device_probe.summary in {"Device Locked", "Lock State Unknown"}:
locked_serial = (
device_probe.metadata.get("active_device", {}).get("serial")
or target_serial
or ""
)
detail = (
"Android device is locked. Unlock it and enter the home screen before running a task."
f"Android device {locked_serial} is locked. Unlock it and enter the home screen before running a task.".replace(" ", " ").strip()
if device_probe.summary == "Device Locked"
else "Android device lock state could not be verified. Keep it unlocked on the home screen and try again."
else f"Android device {locked_serial} lock state could not be verified. Keep it unlocked on the home screen and try again.".replace(" ", " ").strip()
)
raise HTTPException(status_code=409, detail=detail)
if device_probe and device_probe.metadata.get("active_device"):
verified_serial = device_probe.metadata["active_device"].get("serial")
if verified_serial:
target_serial = verified_serial
if readiness_engine.get_active_device_serial() != verified_serial:
readiness_engine.set_active_device_serial(verified_serial)
return await task_queue_service.enqueue_tasks(
incoming_goals,
profile=request.profile or "flash",
@@ -100,7 +115,10 @@ async def run_task(request: RunRequest):
enable_outputter=request.enable_outputter,
locked_app_package=request.locked_app_package,
app_path=request.app_path,
device_serial=request.device_serial,
device_serial=target_serial,
ingress=request.ingress or "frontend",
session_id=request.session_id,
conversation_id=request.conversation_id,
)
@@ -112,13 +130,39 @@ async def list_devices():
@router.post("/api/stop")
async def stop_task(all: bool = False):
stopped = task_queue_service.stop_tasks(clear_all=all)
async def stop_task(
request: Request,
all: bool = False,
session_id: str | None = None,
device_id: str | None = None,
):
target_all = all
target_sid = session_id
target_dev = device_id
try:
body = await request.json()
if isinstance(body, dict):
if "all" in body:
target_all = bool(body["all"]) or target_all
if body.get("session_id"):
target_sid = str(body["session_id"])
if body.get("device_id"):
target_dev = str(body["device_id"])
except Exception:
pass
stopped = task_queue_service.stop_tasks(
clear_all=target_all,
session_id=target_sid,
device_id=target_dev,
)
if stopped:
return {"status": "stopped"}
return {"status": "stopped", "session_id": target_sid}
return {"status": "no_running_task"}
@router.post("/api/resume")
async def resume_task():
resumed = task_queue_service.resume_task()
@@ -258,8 +302,9 @@ async def get_status():
}
@router.get("/api/stream")
@router.get("/api/stream/{session_id}")
async def stream_events(session_id: str, client: str | None = None):
async def stream_events(session_id: str = "active", client: str | None = None):
async def event_generator():
queue = asyncio.Queue()
event_loop = asyncio.get_running_loop()
@@ -295,7 +340,45 @@ async def stream_events(session_id: str, client: str | None = None):
state.add_subscriber(callback)
yield f'event: info\ndata: {{"message": "Subscribed to session {session_id}"}}\n\n'
if session_id not in ("all", "active"):
if session_id in ("all", "active"):
active_sid = state.active_session_id
if not active_sid:
running_item = next(
(t for t in state.queue_items if isinstance(t, dict) and t.get("status") == "running"),
None,
)
if running_item:
active_sid = running_item.get("session_id")
if not active_sid:
owner = DeviceExecutionLock.get_active_owner()
if owner and owner.session_id:
active_sid = owner.session_id
if active_sid:
goal = state.current_goal or ""
profile = state.current_profile or "flash"
yield (
"event: session_started\n"
f"data: {json.dumps({'session_id': str(active_sid), 'initial_goal': goal, 'profile': profile}, default=str)}\n\n"
)
for progress_event in state.get_startup_progress(str(active_sid)):
yield (
"event: startup_progress\n"
f"data: {json.dumps(progress_event, default=str)}\n\n"
)
try:
from apps.admin_console.database.repositories.step_repository import step_repo
recorded_steps = step_repo.get_session_steps(str(active_sid))
for step_dict in recorded_steps:
yield (
"event: step_recorded\n"
f"data: {json.dumps(step_dict, default=str)}\n\n"
)
except Exception as exc:
print(f"[Stream] Could not replay active steps: {exc}")
else:
for progress_event in state.get_startup_progress(session_id):
yield (
"event: startup_progress\n"
+10
View File
@@ -24,6 +24,9 @@ class RunRequest(BaseModel):
locked_app_package: str | None = None
app_path: str | None = None
device_serial: str | None = None
ingress: str | None = "frontend"
session_id: str | None = None
conversation_id: str | None = None
@@ -32,3 +35,10 @@ class ReplayRequest(BaseModel):
user_submits: dict
tool_name: str = "ask_explorer"
replay_id: str | None = None
class StopRequest(BaseModel):
session_id: str | None = None
device_id: str | None = None
all: bool = False
+206 -40
View File
@@ -207,7 +207,7 @@ class TaskQueueService:
while True:
try:
return await asyncio.wait_for(proc.wait(), timeout=1.0)
except asyncio.TimeoutError:
except TimeoutError:
pid = getattr(proc, "pid", None)
if pid:
try:
@@ -313,7 +313,7 @@ class TaskQueueService:
env["ARTEMIS_IPC_PORT"] = str(state.ipc_port)
if sess_id:
env["ARTEMIS_SESSION_ID"] = str(sess_id)
env["ARTEMIS_TASK_INGRESS"] = "frontend"
env["ARTEMIS_TASK_INGRESS"] = str(task_item.get("ingress", "frontend"))
queue_ticket = task_item.get("queue_ticket")
if queue_ticket:
env[DeviceExecutionLock.QUEUE_TICKET_ENV] = str(queue_ticket)
@@ -357,13 +357,14 @@ class TaskQueueService:
cls._broadcast_startup_progress(
sess_id, "process_ready", "Execution process started"
)
ingress_type = str(task_item.get("ingress", "frontend"))
DeviceExecutionLock.transfer_reservation(
str(queue_ticket),
proc.pid,
description=f"frontend task: {goal[:120]}",
description=f"{ingress_type} task: {goal[:120]}",
device_id=device_serial or "pending",
session_id=str(sess_id) if sess_id else None,
ingress="frontend",
ingress=ingress_type,
)
if sys.platform == "win32" and isinstance(proc.stdout, asyncio.StreamReader):
output_task = asyncio.create_task(cls._forward_worker_output(proc.stdout))
@@ -437,6 +438,21 @@ class TaskQueueService:
},
)
conversation_id = task_item.get("conversation_id") if task_item else None
if conversation_id:
try:
from mcp_server.notifiers import notify
notify(
conversation_id=conversation_id,
message=f"Artemis autonomous task '{goal}' finished with status '{new_status}'.\nTrace ID: {sess_id}",
title=f"Task {new_status.capitalize()}: {goal[:40]}",
event_type=new_status,
payload={"session_id": sess_id, "status": new_status, "goal": goal},
)
except Exception as notif_err:
print(f"[QueueWorker] Notification dispatch notice: {notif_err}")
except asyncio.CancelledError:
print("[QueueWorker] Task received cancellation signal.")
if state.current_process and state.current_process.returncode is None:
@@ -467,6 +483,9 @@ class TaskQueueService:
locked_app_package: str | None = None,
app_path: str | None = None,
device_serial: str | None = None,
ingress: str = "frontend",
session_id: str | None = None,
conversation_id: str | None = None,
) -> dict[str, Any]:
"""Enqueues one or more goals and wakes up the background worker."""
cls.ensure_worker_running()
@@ -474,8 +493,15 @@ class TaskQueueService:
enqueued_tasks = []
now = time.time()
for i, goal in enumerate(goals):
sess_id = str(uuid.uuid4())
sess_id = (session_id if (session_id and len(goals) == 1) else str(uuid.uuid4()))
assigned_serial = device_serial
if not assigned_serial:
try:
from artemis.core.diagnostics import readiness_engine
assigned_serial = readiness_engine.get_active_device_serial()
except Exception:
assigned_serial = None
if not assigned_serial:
try:
from artemis.runtime import device_pool
@@ -485,10 +511,10 @@ class TaskQueueService:
assigned_serial = None
queue_ticket = DeviceExecutionLock.reserve(
description=f"frontend task: {goal[:120]}",
description=f"{ingress} task: {goal[:120]}",
device_id=assigned_serial or "pending",
session_id=sess_id,
ingress="frontend",
ingress=ingress,
)
task_item = {
"session_id": sess_id,
@@ -499,6 +525,8 @@ class TaskQueueService:
"locked_app_package": locked_app_package,
"app_path": app_path,
"device_serial": assigned_serial,
"ingress": ingress,
"conversation_id": conversation_id,
"status": "pending",
"queue_ticket": queue_ticket,
"created_at": now + i * 0.001,
@@ -521,17 +549,132 @@ class TaskQueueService:
}
@classmethod
def stop_tasks(cls, clear_all: bool = False) -> bool:
"""Stop the one task currently controlling the global Artemis device.
def stop_tasks(
cls,
clear_all: bool = False,
session_id: str | None = None,
device_id: str | None = None,
) -> bool:
"""Stop the active task controlling a mobile device or all tasks.
The active lease is shared by frontend, MCP, CLI, SDK, and other UI
processes. Pending tasks are intentionally preserved unless the legacy
``clear_all`` option is explicitly requested, in which case only this
frontend server's pending submissions are cleared.
processes across all connected devices.
- If ``session_id`` or ``device_id`` is specified, only the corresponding
running or queued task is cancelled.
- If ``clear_all`` is requested, all active device owners are terminated
and pending queue submissions are cleared.
- If no specific task is specified and ``clear_all`` is False, stops the
currently active task in single-device mode for backward compatibility.
"""
stopped = False
owner = DeviceExecutionLock.get_active_owner()
owner_record_exists = DeviceExecutionLock.has_owner_record()
target_sid = str(session_id).strip() if session_id else None
target_device = str(device_id).strip() if device_id else None
if clear_all:
# 1. Cancel local queue reservations
for item in state.queue_items:
if isinstance(item, dict) and item.get("status") != "running":
DeviceExecutionLock.cancel_reservation(item.get("queue_ticket"))
state.clear_queue()
# 2. Terminate all active owners across all devices
active_owners: dict[str, Any] = {}
try:
active_owners = DeviceExecutionLock.get_active_owners()
except Exception:
pass
fallback = DeviceExecutionLock.get_active_owner()
if fallback and not active_owners:
active_owners["default"] = fallback
for dev_owner in list(active_owners.values()):
if dev_owner and DeviceExecutionLock.is_active_owner(dev_owner):
process_supervisor.terminate_tree_verified(
dev_owner.pid,
dev_owner.process_created_at,
)
DeviceExecutionLock.cleanup_stale_locks(dev_owner.device_id)
sid = dev_owner.session_id
if sid:
session_repo.update_session_status(str(sid), "cancelled", time.time())
if dev_owner.ingress == "mcp":
try:
from mcp_server.utils import trace_store
trace_store.update_trace_status(
str(sid),
"cancelled",
error="Task stopped from the Artemis frontend.",
)
except Exception:
pass
cls._broadcast_event(
"session_ended",
{
"session_id": sid,
"status": "cancelled",
"was_stopped_manually": True,
},
)
if state.current_process:
state.was_stopped_manually = True
try:
state.current_process.kill()
except Exception:
pass
state.current_process = None
state.active_connections.clear()
state.active_session_id = None
state.current_goal = None
state.current_profile = None
if PAUSE_FILE.exists():
try:
PAUSE_FILE.unlink()
except Exception:
pass
cls.ensure_worker_running()
state.wake_event.set()
return True
# Stop a specific task (or default single-device active task)
active_owners = {}
try:
active_owners = DeviceExecutionLock.get_active_owners()
except Exception:
pass
owner = None
if target_sid:
for dev_owner in active_owners.values():
if dev_owner.session_id and str(dev_owner.session_id) == target_sid:
owner = dev_owner
break
if owner is None and target_device:
clean_target_dev = DeviceExecutionLock._normalize_device_id(target_device)
for dev_key, dev_owner in active_owners.items():
if dev_key == clean_target_dev or dev_owner.device_id == target_device:
owner = dev_owner
break
if owner is None:
fallback_owner = DeviceExecutionLock.get_active_owner(target_device)
if fallback_owner:
if not target_sid or (
fallback_owner.session_id and str(fallback_owner.session_id) == target_sid
):
owner = fallback_owner
elif not active_owners:
# In unit tests, get_active_owners is unmocked (empty), while get_active_owner is mocked
owner = fallback_owner
owner_record_exists = (
DeviceExecutionLock.has_owner_record(target_device)
if hasattr(DeviceExecutionLock, "has_owner_record")
else False
)
owner_pid = owner.pid if owner else None
local_pid = getattr(state.current_process, "pid", None)
is_local_owner = bool(owner_pid and local_pid and owner_pid == local_pid)
@@ -540,7 +683,9 @@ class TaskQueueService:
(
item
for item in state.queue_items
if isinstance(item, dict) and item.get("status") == "running"
if isinstance(item, dict)
and item.get("status") == "running"
and (not target_sid or str(item.get("session_id")) == target_sid)
),
None,
)
@@ -548,37 +693,38 @@ class TaskQueueService:
(
item
for item in state.queue_items
if isinstance(item, dict) and item.get("status") == "pending"
if isinstance(item, dict)
and item.get("status") == "pending"
and (not target_sid or str(item.get("session_id")) == target_sid)
),
None,
)
stopped_session_id = (
owner.session_id
if owner and owner.session_id
else target_sid
if target_sid
else state.active_session_id
if owner
if is_local_owner
else local_item.get("session_id")
if local_item
else None
)
if clear_all:
for item in state.queue_items:
if isinstance(item, dict) and item.get("status") != "running":
DeviceExecutionLock.cancel_reservation(item.get("queue_ticket"))
state.clear_queue()
reservation_cancelled = False
if owner and DeviceExecutionLock.is_active_owner(owner):
stopped = process_supervisor.terminate_tree_verified(
owner.pid,
owner.process_created_at,
)
DeviceExecutionLock.cleanup_stale_locks()
elif owner is None and owner_record_exists:
DeviceExecutionLock.cleanup_stale_locks(owner.device_id)
elif owner is None and owner_record_exists and not target_sid:
# Never fall back to a frontend PID while another process has an
# owner record that is still being published or cannot be parsed.
return False
elif owner is None and state.current_process:
elif owner is None and state.current_process and (
not target_sid or str(state.active_session_id) == target_sid
):
# The frontend worker can be stopped during its short initialization
# window before Agent acquires the global device lease.
state.was_stopped_manually = True
@@ -592,21 +738,42 @@ class TaskQueueService:
stopped = True
except Exception:
pass
# A dead worker is already stopped; cleanup must still be treated
# as a successful, idempotent stop operation.
stopped = True
is_local_owner = True
elif owner is None and local_item:
elif owner is None and local_item and (
not target_sid or str(local_item.get("session_id")) == target_sid
):
# Cancel a frontend submission before its worker has started. This
# does not touch pending reservations created by other ingresses.
DeviceExecutionLock.cancel_reservation(local_item.get("queue_ticket"))
reservation_cancelled = True
stopped = True
is_local_owner = True
elif target_sid:
# Fallback 1: check global device queue
global_queued = DeviceExecutionLock.get_queued_tasks()
for q_item in global_queued:
if str(q_item.get("session_id")) == target_sid:
ticket = q_item.get("queue_ticket")
if ticket:
DeviceExecutionLock.cancel_reservation(ticket)
stopped = True
break
# Fallback 2: check session repository for a running session with a live worker PID
if not stopped:
row = session_repo.get_session_by_id(target_sid)
if row and row.get("status") == "running":
row_pid = row.get("pid")
if row_pid and session_repo.process_is_alive(row_pid):
try:
process_supervisor.terminate_tree(int(row_pid))
except Exception:
pass
DeviceExecutionLock.cleanup_stale_locks()
stopped = True
if not stopped:
if clear_all:
state.wake_event.set()
return clear_all
if not stopped and not target_sid:
return False
if is_local_owner:
state.was_stopped_manually = True
@@ -614,11 +781,11 @@ class TaskQueueService:
if stopped_session_id:
state.cancelled_session_ids.add(str(stopped_session_id))
for session_id, conn_info in list(state.active_connections.items()):
if (stopped_session_id and str(session_id) == str(stopped_session_id)) or (
for sid, conn_info in list(state.active_connections.items()):
if (stopped_session_id and str(sid) == str(stopped_session_id)) or (
owner_pid and conn_info.get("pid") == owner_pid
):
state.active_connections.pop(session_id, None)
state.active_connections.pop(sid, None)
if stopped_session_id:
session_repo.update_session_status(
@@ -652,7 +819,7 @@ class TaskQueueService:
state.current_goal = None
state.current_profile = None
if is_local_owner and stopped_session_id:
if stopped_session_id:
stopped_item = next(
(
item
@@ -662,7 +829,7 @@ class TaskQueueService:
),
None,
)
if stopped_item:
if stopped_item and not reservation_cancelled:
DeviceExecutionLock.cancel_reservation(stopped_item.get("queue_ticket"))
state.queue_items = [
item
@@ -672,8 +839,6 @@ class TaskQueueService:
and str(item.get("session_id")) == str(stopped_session_id)
)
]
if clear_all:
state.queue_items = []
if PAUSE_FILE.exists():
try:
@@ -685,6 +850,7 @@ class TaskQueueService:
state.wake_event.set()
return True
@classmethod
def resume_task(cls) -> bool:
if PAUSE_FILE.exists():
@@ -74,6 +74,16 @@
</span>
<span class="item-time-divider" *ngIf="getDeviceSerial(session)">·</span>
<span class="item-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
<button
*ngIf="getTaskStatus(session) === 'running' || getTaskStatus(session) === 'paused'"
type="button"
class="btn-stop-card"
(click)="stopTask(session.session_id, $event)"
title="Stop this task"
aria-label="Stop this task"
>
<span class="material-symbols-outlined">stop_circle</span>
</button>
<button
*ngIf="getTaskStatus(session) !== 'running' && getTaskStatus(session) !== 'paused'"
type="button"
@@ -551,11 +551,14 @@ $stream-row-height: 25px;
.item-header .item-header-actions {
&:has(.btn-delete-card) .item-time,
&:has(.btn-delete-card) .item-time-divider {
&:has(.btn-delete-card) .item-time-divider,
&:has(.btn-stop-card) .item-time,
&:has(.btn-stop-card) .item-time-divider {
opacity: 0;
}
.btn-delete-card {
.btn-delete-card,
.btn-stop-card {
opacity: 1;
pointer-events: auto;
}
@@ -756,6 +759,38 @@ $stream-row-height: 25px;
background: #fee2e2;
}
}
.btn-stop-card {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
width: 18px;
height: 18px;
padding: 0;
border: none;
background: transparent;
border-radius: 3px;
color: #ef4444;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: none;
transition: all 0.15s ease;
.material-symbols-outlined {
font-size: 14px;
line-height: 1;
}
&:hover {
color: #dc2626;
background: #fee2e2;
transform: translateY(-50%) scale(1.1);
}
}
}
}
@@ -643,6 +643,11 @@ export class AgentStreamComponent implements AfterViewInit {
this.isTaskDropdownOpen.set(false);
}
public stopTask(sessionId: string, event?: Event): void {
if (event) event.stopPropagation();
this.agentService.stopTask(sessionId, false);
}
public deleteTask(sessionId: string, event?: Event): void {
if (event) event.stopPropagation();
if (!confirm(`Are you sure you want to delete this task? This cannot be undone.`)) {
@@ -83,6 +83,16 @@
</span>
<span class="task-time-divider" *ngIf="getDeviceSerial(session)">·</span>
<span class="task-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
<button
*ngIf="getTaskStatus(session) === 'running' || getTaskStatus(session) === 'paused'"
type="button"
class="btn-stop-card"
(click)="stopTask(session.session_id, $event)"
title="Stop this task"
aria-label="Stop this task"
>
<span class="material-symbols-outlined">stop_circle</span>
</button>
<button
*ngIf="getTaskStatus(session) !== 'running' && getTaskStatus(session) !== 'paused'"
type="button"
@@ -297,11 +297,14 @@ $text-muted: #a1a1aa;
.task-card-header .task-card-header-actions {
&:has(.btn-delete-card) .task-time,
&:has(.btn-delete-card) .task-time-divider {
&:has(.btn-delete-card) .task-time-divider,
&:has(.btn-stop-card) .task-time,
&:has(.btn-stop-card) .task-time-divider {
opacity: 0;
}
.btn-delete-card {
.btn-delete-card,
.btn-stop-card {
opacity: 1;
pointer-events: auto;
}
@@ -508,6 +511,43 @@ $text-muted: #a1a1aa;
cursor: not-allowed;
}
}
.btn-stop-card {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
width: 20px;
height: 20px;
padding: 0;
border: none;
background: transparent;
border-radius: 4px;
color: #ef4444;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: none;
transition: all 0.15s ease;
.material-symbols-outlined {
font-size: 15px;
line-height: 1;
}
&:hover:not(:disabled) {
color: #dc2626;
background: #fee2e2;
transform: translateY(-50%) scale(1.1);
}
&:disabled {
opacity: 0.3;
cursor: not-allowed;
}
}
}
}
@@ -98,6 +98,19 @@ export class ChatInterfaceComponent {
});
}
/**
* Stop an individual task session
*/
public stopTask(sessionId: string, event: MouseEvent): void {
event.stopPropagation();
this.isSubmitting = true;
this.errorMessage = null;
this.agentService.stopTask(sessionId, false);
setTimeout(() => {
this.isSubmitting = false;
}, 400);
}
/**
* Stop the currently running task and optionally clear the backend queue
*/
@@ -1375,6 +1375,31 @@
<span>{{ isRefreshingDiagnostics() ? 'Checking...' : 'Check Again' }}</span>
</button>
</div>
<!-- Multi-Device Switcher if multiple devices detected -->
@if (connectedDevices().length > 1) {
<div class="multi-device-switcher" style="margin-top: 12px;">
<span class="switcher-label">Select Active Device:</span>
<div class="switcher-chips">
@for (dev of connectedDevices(); track dev.serial) {
<button
type="button"
class="device-chip-btn"
[class.selected]="dev.serial === activeDevice()?.serial"
(click)="selectTargetDevice(dev.serial)"
>
<span class="material-symbols-outlined chip-icon">
phone_android
</span>
<span>{{ dev.model || dev.serial }}</span>
@if (dev.serial === activeDevice()?.serial) {
<span class="active-check">✓ Active</span>
}
</button>
}
</div>
</div>
}
</div>
}
@@ -116,11 +116,11 @@
<span class="material-symbols-outlined">close</span>
</button>
<!-- Stop Button (Visible when task is running) -->
<!-- Stop Button (Visible when currently viewed task is active) -->
<button *ngIf="isTaskRunning()" type="button" class="dock-circle-btn btn-stop" [disabled]="isSubmitting"
(click)="stopTask($event)"
title="Stop running task"
aria-label="Stop running task"
[title]="stopButtonTitle()"
[attr.aria-label]="stopButtonTitle()"
>
<span class="material-symbols-outlined">stop</span>
</button>
@@ -161,12 +161,12 @@
</div>
</div>
<!-- Separate Standalone Stop Button Beside the Capsule (Visible when task is running) -->
<!-- Separate Standalone Stop Button Beside the Capsule (Visible when currently viewed task is active) -->
<button *ngIf="isTaskRunning()" type="button" class="standalone-stop-btn"
[disabled]="isSubmitting"
(click)="stopTask($event)"
title="Stop running task"
aria-label="Stop running task"
[title]="stopButtonTitle()"
[attr.aria-label]="stopButtonTitle()"
>
<span class="material-symbols-outlined">stop</span>
</button>
@@ -77,10 +77,29 @@ export class WorkspaceComponent implements OnInit {
}
/**
* Computed boolean whether a task is currently executing or active
* Computed boolean whether the currently viewed task is actively running or paused.
* Only displays the stop/cancel button when inspecting an active task.
*/
public isTaskRunning = computed(() => {
return this.agentService.isRunningTask();
return this.agentService.isCurrentSessionRunning();
});
/**
* Dynamic tooltip and label indicating which task will be stopped
*/
public stopButtonTitle = computed(() => {
const session = this.agentService.currentSession();
if (session?.initial_goal) {
const truncated = session.initial_goal.length > 45
? session.initial_goal.substring(0, 42) + '...'
: session.initial_goal;
return `Stop current task: "${truncated}"`;
}
const curId = this.agentService.currentSessionId();
if (curId) {
return `Stop current task (${curId})`;
}
return 'Stop current running task';
});
/**
@@ -216,7 +235,7 @@ export class WorkspaceComponent implements OnInit {
}
/**
* Stop currently running task
* Stop currently viewed task
*/
public stopTask(event?: MouseEvent): void {
if (event) {
@@ -225,9 +244,10 @@ export class WorkspaceComponent implements OnInit {
if (!this.isTaskRunning()) {
return;
}
const targetSessionId = this.agentService.currentSessionId();
this.isSubmitting = true;
this.errorMessage = null;
this.agentService.stopTask(false);
this.agentService.stopTask(targetSessionId, false);
setTimeout(() => {
this.isSubmitting = false;
}, 400);
@@ -275,3 +275,108 @@ describe('AgentService video analysis seeking', () => {
expect(second?.requestId).toBeGreaterThan(first?.requestId || 0);
});
});
describe('AgentService task cancellation and active session tracking', () => {
it('computes isCurrentSessionRunning true only when viewing an active running/paused session', () => {
const service = Object.create(AgentService.prototype) as any;
service.currentSessionId = signal<string | null>(null);
service.rawSessions = signal<any[]>([]);
service.activeTasks = signal<any[]>([]);
service.pendingQueue = signal<any[]>([]);
service.agentStatus = signal('idle');
service.runningSessionId = signal<string | null>(null);
service.runningGoal = signal<string | null>(null);
service.activeSessionTracking = new Map();
service.currentSession = computed(() => {
const curId = service.currentSessionId();
if (!curId) return null;
return service.sessions().find((s: any) => s.session_id === curId) || null;
});
service.sessions = computed(() => {
return service.rawSessions();
});
service.isCurrentSessionRunning = computed(() => {
const curId = service.currentSessionId();
if (!curId) return false;
const session = service.currentSession();
if (session) {
return session.status === 'running' || session.status === 'paused';
}
const isActiveStatus = service.agentStatus() === 'running' || service.agentStatus() === 'paused';
if (isActiveStatus && service.runningSessionId() === curId) {
return true;
}
if (service.activeTasks().some((at: any) => at.session_id === curId)) {
return true;
}
return false;
});
// 1. No session selected -> false
expect(service.isCurrentSessionRunning()).toBeFalse();
// 2. Completed session selected -> false
service.rawSessions.set([
{ session_id: 'sess-completed', status: 'completed', initial_goal: 'Done', start_time: 100 },
{ session_id: 'sess-running', status: 'running', initial_goal: 'Running', start_time: 101 }
]);
service.currentSessionId.set('sess-completed');
expect(service.isCurrentSessionRunning()).toBeFalse();
// 3. Active running session selected -> true
service.currentSessionId.set('sess-running');
expect(service.isCurrentSessionRunning()).toBeTrue();
// 4. Paused session selected -> true
service.rawSessions.set([
{ session_id: 'sess-paused', status: 'paused', initial_goal: 'Paused', start_time: 102 }
]);
service.currentSessionId.set('sess-paused');
expect(service.isCurrentSessionRunning()).toBeTrue();
});
it('stops a specific session by passing its session_id', () => {
const service = Object.create(AgentService.prototype) as AgentService;
service.currentSessionId = signal<string | null>('sess-2');
service.runningSessionId = signal<string | null>('sess-1');
service.runningGoal = signal<string | null>('Goal 1');
service.agentStatus = signal('running');
service.isPaused = signal(false);
service.pausedError = signal<string | null>(null);
service.isRetrying = signal(false);
service.sessionLogs = signal<any[]>([]);
service.activeTasks = signal<any[]>([{ session_id: 'sess-1' }, { session_id: 'sess-2' }]);
(service as any).pendingQueue = signal<any[]>([]);
(service as any).rawSessions = signal<any[]>([
{ session_id: 'sess-1', status: 'running', initial_goal: 'Goal 1' },
{ session_id: 'sess-2', status: 'running', initial_goal: 'Goal 2' }
]);
service.sessions = computed(() => (service as any).rawSessions());
let postedUrl = '';
let postedPayload: any = null;
(service as any).http = {
post: (url: string, payload: any) => {
postedUrl = url;
postedPayload = payload;
return of({});
}
};
(service as any).setSessionStatus = (sid: string, st: string) => {};
(service as any).fetchStatus = () => {};
(service as any).fetchSessions = () => {};
// Stop sess-2 specifically
service.stopTask('sess-2', false);
expect(postedUrl).toContain('session_id=sess-2');
expect(postedPayload?.session_id).toBe('sess-2');
// Because sess-1 is still running, agentStatus should NOT be reset to idle
expect(service.agentStatus()).toBe('running');
// activeTasks should have filtered out sess-2
expect(service.activeTasks().map((at: any) => at.session_id)).toEqual(['sess-1']);
});
});
+182 -130
View File
@@ -275,12 +275,25 @@ export class AgentService {
});
/**
* Computed whether the currently viewed session is running
* Computed whether the currently viewed session is actively running or paused.
* Returns false when viewing completed/failed/cancelled tasks, or when no session is selected.
*/
public isCurrentSessionRunning = computed(() => {
const curId = this.currentSessionId();
if (!curId) return false;
const session = this.currentSession();
if (session) return session.status === 'running' || session.status === 'paused';
return this.agentStatus() === 'running' || this.agentStatus() === 'paused';
if (session) {
return session.status === 'running' || session.status === 'paused';
}
// Fallback if session was just submitted/selected but not yet merged into sessions list
const isActiveStatus = this.agentStatus() === 'running' || this.agentStatus() === 'paused';
if (isActiveStatus && this.runningSessionId() === curId) {
return true;
}
if (this.activeTasks().some((at) => at.session_id === curId)) {
return true;
}
return false;
});
/**
@@ -312,6 +325,7 @@ export class AgentService {
this.restoreSessionsCache();
this.fetchSessions();
this.startStatusPolling();
this.ensureLiveStream();
}
/**
@@ -382,43 +396,86 @@ export class AgentService {
}
/**
* Stop the currently running task (and optionally clear queue)
* Stop the currently viewed task (or specified task), and optionally clear queue.
* Supports stopping any task across any ingress/platform (Web, CLI, MCP, SDK).
*/
public stopTask(stopAll: boolean = false): void {
const stoppingSessionId = this.runningSessionId()
|| this.sessions().find((session) => session.status === 'running' || session.status === 'paused')?.session_id
|| null;
public stopTask(targetOrStopAll?: string | boolean | null, stopAll: boolean = false): void {
let targetSessionId: string | null = null;
let effectiveStopAll = stopAll;
if (typeof targetOrStopAll === 'boolean') {
effectiveStopAll = targetOrStopAll;
} else if (typeof targetOrStopAll === 'string' && targetOrStopAll.trim()) {
targetSessionId = targetOrStopAll.trim();
} else {
// Default to currently viewed session, or currently running session
targetSessionId = this.currentSessionId() || this.runningSessionId() || null;
}
if (!effectiveStopAll && !targetSessionId) {
targetSessionId = this.sessions().find(
(session) => session.status === 'running' || session.status === 'paused'
)?.session_id || null;
}
// Keep the task's terminal state stable while the stop request and the
// persisted session update cross the network. Clearing the global runner
// state first used to make the session merger infer "completed" from the
// still-stale DB row, causing a brief completed -> cancelled flicker.
this.setSessionStatus(stoppingSessionId, 'cancelled');
if (targetSessionId) {
this.setSessionStatus(targetSessionId, 'cancelled');
}
// Apply optimistic updates: only set idle if stopAll is true or pending queue is empty
if (stopAll || this.pendingQueue().length === 0) {
// Determine if other active sessions are still executing across multiple devices
const otherRunningSessions = this.sessions().filter(
(s) => (s.status === 'running' || s.status === 'paused') && s.session_id !== targetSessionId
);
// Apply optimistic updates: only set idle if effectiveStopAll is true or no other tasks are running
if (effectiveStopAll || otherRunningSessions.length === 0) {
this.agentStatus.set('idle');
this.runningSessionId.set(null);
this.runningGoal.set(null);
} else if (this.runningSessionId() === targetSessionId) {
this.runningSessionId.set(otherRunningSessions[0].session_id);
this.runningGoal.set(otherRunningSessions[0].initial_goal || null);
}
this.isPaused.set(false);
this.pausedError.set(null);
this.isRetrying.set(false);
if (stopAll) {
if (effectiveStopAll) {
this.pendingQueue.set([]);
}
// Mark active streaming / pending logs in current session as finished
this.sessionLogs.update((logs) =>
logs.map((l) => {
if (l.type === 'llm_stream' && !l.data?.isCompleted) {
return { ...l, data: { ...l.data, isCompleted: true } };
}
return l;
})
);
// Mark active streaming / pending logs as finished if viewing target session
if (!targetSessionId || this.currentSessionId() === targetSessionId) {
this.sessionLogs.update((logs) =>
logs.map((l) => {
if (l.type === 'llm_stream' && !l.data?.isCompleted) {
return { ...l, data: { ...l.data, isCompleted: true } };
}
return l;
})
);
}
this.http.post<any>(`/api/stop?all=${stopAll}`, {}).subscribe({
// Optimistically remove from activeTasks
if (targetSessionId) {
this.activeTasks.update((list) => list.filter((at) => at.session_id !== targetSessionId));
}
let url = `/api/stop?all=${effectiveStopAll}`;
if (!effectiveStopAll && targetSessionId) {
url += `&session_id=${encodeURIComponent(targetSessionId)}`;
}
const payload: any = { all: effectiveStopAll };
if (targetSessionId) {
payload.session_id = targetSessionId;
}
this.http.post<any>(url, payload).subscribe({
next: () => {
this.fetchStatus();
this.fetchSessions();
@@ -577,10 +634,6 @@ export class AgentService {
this.currentSessionId.set(null);
this.sessionLogs.set([]);
this.currentNotes.set({});
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
return;
}
@@ -605,37 +658,36 @@ export class AgentService {
this.openVideoPlayer(sessionId);
}
// Clean up previous event stream connection if any
console.log(`Selecting session: ${sessionId}`);
// Start reading the persisted snapshot immediately
this.backfillSessionSteps(sessionId, loadGeneration);
this.ensureLiveStream();
}
/**
* Ensure persistent single-channel live stream is established via /api/stream.
* Eliminates reconnect tearing, race conditions, and disconnect dropouts.
*/
public ensureLiveStream(): void {
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
return;
}
console.log(`Connecting to session: ${sessionId}`);
// Start reading the persisted snapshot immediately. Previously this waited
// for the SSE `info` event, adding connection latency to every history click.
this.backfillSessionSteps(sessionId, loadGeneration);
console.log('Establishing persistent unified live stream via /api/stream');
this.eventSource = new EventSource('/api/stream');
this.eventSource = new EventSource(`/api/stream/${sessionId}`);
// Active runs get one reconciliation snapshot after the stream subscribes,
// closing the snapshot-to-stream gap without delaying the first paint.
const selectedStatus = this.sessions().find((session) => session.session_id === sessionId)?.status;
const shouldReconcileAfterSubscribe = selectedStatus === 'running'
|| selectedStatus === 'paused'
|| selectedStatus === 'pending';
this.eventSource.addEventListener('info', () => {
if (shouldReconcileAfterSubscribe) {
this.backfillSessionSteps(sessionId, loadGeneration);
// Reconcile current session if active
const curId = this.currentSessionId();
if (curId) {
this.backfillSessionSteps(curId);
}
});
// Listen to standard SSE keep-alive
this.eventSource.addEventListener('keep-alive', () => {
// No-op keep-alive tick
// Keep-alive tick
});
// Listen to raw events and append them directly to the logs array
const eventTypes = [
'llm_stream',
'trace_recorded',
@@ -656,8 +708,6 @@ export class AgentService {
this.eventSource?.addEventListener(eventType, (event: MessageEvent) => {
try {
const parsedData = JSON.parse(event.data);
// If the event carries a session_id for a different session, update global states if applicable and ignore
const evtSessionId = parsedData?.session_id;
if (eventType === 'recording_ready' || eventType === 'recording_failed') {
@@ -682,50 +732,8 @@ export class AgentService {
return;
}
const isDifferentSession = evtSessionId
&& String(evtSessionId).trim().toLowerCase() !== String(sessionId).trim().toLowerCase();
if (isDifferentSession) {
if (eventType === 'session_started') {
this.agentStatus.set('running');
this.runningSessionId.set(parsedData.session_id);
if (parsedData?.initial_goal) {
this.runningGoal.set(parsedData.initial_goal);
}
this.fetchSessions();
// Only auto-select if user has no current session at all
const currentId = this.currentSessionId();
if (!currentId && parsedData?.session_id) {
this.selectSession(parsedData.session_id, false);
}
} else if (eventType === 'session_ended') {
this.applySessionEndedStatus(evtSessionId, parsedData);
this.activeTasks.update(list => list.filter(at => at.session_id !== evtSessionId));
const remaining = this.activeTasks();
if (remaining.length > 0) {
if (this.runningSessionId() === evtSessionId) {
this.runningSessionId.set(remaining[0].session_id);
this.runningGoal.set(remaining[0].goal || null);
}
} else if (this.pendingQueue().length > 0) {
const nextPending = this.pendingQueue()[0];
this.agentStatus.set('running');
this.runningSessionId.set(nextPending.session_id);
this.runningGoal.set(nextPending.initial_goal || null);
} else {
this.agentStatus.set('idle');
this.runningSessionId.set(null);
this.runningGoal.set(null);
}
this.fetchStatus();
this.fetchSessions();
this.updateModelForCurrentView();
}
return;
}
// Guard against obsolete subscriptions
if (this.currentSessionId() !== sessionId) {
if (eventType === 'background_tasks_updated') {
this.fetchStatus();
return;
}
@@ -738,12 +746,20 @@ export class AgentService {
this.runningGoal.set(parsedData.initial_goal);
}
this.fetchSessions();
// Auto-follow: if user has not explicitly pinned a historical session, switch view to new active session
if (!this.userPinnedSessionId() && parsedData?.session_id) {
this.selectSession(parsedData.session_id, false);
}
return;
}
if (eventType === 'session_ended') {
this.applySessionEndedStatus(sessionId, parsedData);
this.activeTasks.update(list => list.filter(at => at.session_id !== sessionId));
const endedId = evtSessionId || this.runningSessionId();
if (endedId) {
this.applySessionEndedStatus(endedId, parsedData);
this.activeTasks.update(list => list.filter(at => at.session_id !== endedId));
}
const remaining = this.activeTasks();
if (remaining.length > 0) {
this.agentStatus.set('running');
@@ -763,20 +779,40 @@ export class AgentService {
this.isPaused.set(false);
this.fetchSessions();
this.fetchStatus();
this.fetchNotes(sessionId);
this.updateModelForCurrentView();
if (
this.isVideoWindowOpen()
&& this.activeVideoSessionId === sessionId
&& this.recordingPlaybackStatus() === 'live'
) {
this.beginRecordingFinalization(sessionId);
if (endedId) {
this.fetchNotes(endedId);
if (
this.isVideoWindowOpen()
&& this.activeVideoSessionId === endedId
&& this.recordingPlaybackStatus() === 'live'
) {
this.beginRecordingFinalization(endedId);
}
}
this.updateModelForCurrentView();
return;
}
if (eventType === 'startup_progress') {
this.appendStartupProgress(parsedData, sessionId);
const targetSid = evtSessionId || this.currentSessionId();
if (targetSid) {
this.appendStartupProgress(parsedData, targetSid);
const curId = this.currentSessionId();
if (
!this.userPinnedSessionId() &&
(!curId || String(targetSid).trim().toLowerCase() !== String(curId).trim().toLowerCase())
) {
this.agentStatus.set('running');
this.runningSessionId.set(targetSid);
this.selectSession(targetSid, false);
}
}
return;
}
// If the event is for a different session than what user is currently viewing, ignore for logs
const curId = this.currentSessionId();
if (evtSessionId && curId && String(evtSessionId).trim().toLowerCase() !== String(curId).trim().toLowerCase()) {
return;
}
@@ -788,7 +824,9 @@ export class AgentService {
const attemptText = attempt && max ? ` (Attempt ${attempt}/${max})` : '';
const delayText = delay > 0 ? `; retrying in ${delay.toFixed(2).replace(/\.00$/, '')}s` : '';
this.retryMessage.set(`AI service is temporarily busy${attemptText}${delayText}...`);
this.appendLiveLLMRetryTrace(parsedData, sessionId);
if (curId) {
this.appendLiveLLMRetryTrace(parsedData, curId);
}
return;
}
@@ -796,17 +834,19 @@ export class AgentService {
this.isPaused.set(true);
this.isRetrying.set(false);
this.agentStatus.set('paused');
this.runningSessionId.set(sessionId);
this.setSessionStatus(sessionId, 'paused');
const pauseError = parsedData.error || 'AI call failed';
this.pausedError.set(pauseError);
this.appendPausedErrorCard(
pauseError,
sessionId,
parsedData.timestamp,
parsedData.step_id,
parsedData
);
if (curId) {
this.runningSessionId.set(curId);
this.setSessionStatus(curId, 'paused');
const pauseError = parsedData.error || 'AI call failed';
this.pausedError.set(pauseError);
this.appendPausedErrorCard(
pauseError,
curId,
parsedData.timestamp,
parsedData.step_id,
parsedData
);
}
return;
}
@@ -814,9 +854,9 @@ export class AgentService {
this.isPaused.set(false);
this.isRetrying.set(false);
this.pausedError.set(null);
if (this.runningSessionId() === sessionId) {
if (curId && this.runningSessionId() === curId) {
this.agentStatus.set('running');
this.setSessionStatus(sessionId, 'running');
this.setSessionStatus(curId, 'running');
}
this.activePauseCardKey = null;
return;
@@ -833,7 +873,6 @@ export class AgentService {
const streamType = parsedData.stream_type || 'text';
this.sessionLogs.update((logs) => {
// Find if there is an active stream log with same execution_id and stream_type
const existingIndex = logs.findIndex(
(l) => l.type === 'llm_stream' &&
l.data.execution_id === execId &&
@@ -841,7 +880,6 @@ export class AgentService {
);
if (existingIndex > -1) {
// Append text in place
const updatedLogs = [...logs];
const existingLog = updatedLogs[existingIndex];
updatedLogs[existingIndex] = {
@@ -854,7 +892,6 @@ export class AgentService {
};
return updatedLogs;
} else {
// First mark any other active streams of same stream_type as completed
const updatedLogs = logs.map((l) => {
if (l.type === 'llm_stream' &&
(l.data.stream_type || 'text') === streamType &&
@@ -867,7 +904,6 @@ export class AgentService {
return l;
});
// Add new stream log
return [
...updatedLogs,
{
@@ -885,7 +921,6 @@ export class AgentService {
}
});
} else {
// For other event types, mark any active stream as completed
this.sessionLogs.update((logs) => {
const updatedLogs = logs.map((l) => {
if (l.type === 'llm_stream' && !l.data.isCompleted) {
@@ -907,9 +942,6 @@ export class AgentService {
data: parsedData
};
// A retry is published both as trace_recorded and as the
// purpose-built llm_retrying event. Replace the optimistic live
// copy when the persisted trace arrives instead of duplicating it.
if (eventType === 'trace_recorded' && parsedData?.trace_id) {
const existingTraceIndex = updatedLogs.findIndex((log) =>
log.type === 'trace_recorded'
@@ -930,14 +962,13 @@ export class AgentService {
const trName = parsedData.name || '';
if (['save_note', 'read_note', 'update_note', 'append_note', 'list_notes', 'outputter'].includes(trName.toLowerCase())) {
const curSessionId = this.currentSessionId();
if (curSessionId === sessionId) {
if (curSessionId) {
this.fetchNotes(curSessionId);
}
}
}
} catch (e) {
console.error(`Failed to parse ${eventType} event data:`, e);
// Fallback to plain event data if parsing fails
this.sessionLogs.update((logs) => [
...logs,
{
@@ -951,7 +982,7 @@ export class AgentService {
});
this.eventSource.onerror = (err) => {
console.error('EventSource connection error:', err);
console.warn('Persistent live stream issue, browser will auto-reconnect:', err);
};
}
@@ -998,6 +1029,27 @@ export class AgentService {
}
}
});
this.http.get<StartupProgressEvent[]>(`/api/sessions/${sessionId}/startup_progress`).subscribe({
next: (events) => {
if (Array.isArray(events) && events.length > 0) {
this.startupProgressBySession.update((current) => {
const existing = current[sessionId] || [];
const merged = [...existing];
for (const ev of events) {
if (!merged.some((m) => m.stage === ev.stage)) {
merged.push(ev);
}
}
return {
...current,
[sessionId]: merged
};
});
}
},
error: () => {}
});
}
private restoreSessionsCache(): void {
@@ -1268,10 +1320,10 @@ export class AgentService {
this.beginRecordingFinalization(oldRunningSessionId);
}
// Auto-select the session ONLY if nothing is currently selected
// Auto-select the active session if user has not explicitly pinned a historical session
if (isActive && data.session_id) {
const currentId = this.currentSessionId();
if (!currentId) {
if (!currentId || (!this.userPinnedSessionId() && currentId !== data.session_id)) {
this.selectSession(data.session_id, false);
}
}
+12 -3
View File
@@ -90,7 +90,14 @@ class ReadinessEngine:
def get_active_device_serial(self) -> str | None:
"""Get currently selected active device serial."""
return self._active_device_serial
if self._report_cache and self._report_cache.active_device:
if self._report_cache.active_device.is_locked is False:
return self._report_cache.active_device.serial
if self._active_device_serial:
return self._active_device_serial
if self._report_cache and self._report_cache.active_device:
return self._report_cache.active_device.serial
return None
async def run_probe(self, probe_id: str) -> ProbeResult | None:
"""Execute a single specific probe by ID."""
@@ -99,9 +106,11 @@ class ReadinessEngine:
return None
return await probe.probe()
async def run_device_submission_probe(self) -> ProbeResult:
async def run_device_submission_probe(
self, target_serial: str | None = None
) -> ProbeResult:
"""Run the bounded device gate used by task submission."""
return await self._adb_probe.probe_submission_readiness()
return await self._adb_probe.probe_submission_readiness(target_serial=target_serial)
def invalidate_cache(self) -> None:
"""Invalidate the UI readiness snapshot after an explicit configuration change."""
+45 -7
View File
@@ -358,7 +358,9 @@ class AdbDeviceProbe(BaseProbe):
logger.debug(f"Failed submission-time ADB device check: {exc}")
return None
async def probe_submission_readiness(self) -> ProbeResult:
async def probe_submission_readiness(
self, target_serial: str | None = None
) -> ProbeResult:
"""Run the minimal fail-safe device check required before enqueueing.
The full diagnostics probe enriches device metadata, scans packages,
@@ -416,16 +418,42 @@ class AdbDeviceProbe(BaseProbe):
},
)
serial = (
self._target_serial
preferred = (
target_serial
if target_serial and target_serial in ready_serials
else self._target_serial
if self._target_serial in ready_serials
else ready_serials[0]
)
serial = preferred
is_locked = await self._get_confirmed_device_lock_state(
adb_path,
serial,
timeout_seconds=1.0,
)
# If preferred device is locked or unknown, check remaining ready devices for an unlocked one
if is_locked is not False and len(ready_serials) > 1:
for candidate in ready_serials:
if candidate == preferred:
continue
candidate_locked = await self._get_confirmed_device_lock_state(
adb_path,
candidate,
timeout_seconds=1.0,
)
if candidate_locked is False:
logger.info(
f"Device {preferred} is not ready/unlocked ({is_locked}); "
f"switching to unlocked ready device {candidate}"
)
serial = candidate
is_locked = False
break
elif candidate_locked is None and is_locked is True:
serial = candidate
is_locked = None
summary = (
"Device Locked"
if is_locked is True
@@ -796,11 +824,21 @@ class AdbDeviceProbe(BaseProbe):
)
# 4. Ready device available
active_dev = ready_devices[0]
preferred_dev = None
if self._target_serial:
matched = next((d for d in ready_devices if d.serial == self._target_serial), None)
if matched:
active_dev = matched
preferred_dev = next((d for d in ready_devices if d.serial == self._target_serial), None)
if preferred_dev and preferred_dev.is_locked is False:
active_dev = preferred_dev
else:
# Prefer a confirmed unlocked ready device
unlocked_dev = next((d for d in ready_devices if d.is_locked is False), None)
if unlocked_dev:
active_dev = unlocked_dev
else:
# If no confirmed unlocked device, prefer undetermined lock state over confirmed locked
unknown_dev = next((d for d in ready_devices if d.is_locked is None), None)
active_dev = preferred_dev or unknown_dev or ready_devices[0]
display_name = active_dev.model or active_dev.serial
if active_dev.screen_resolution:
+3 -3
View File
@@ -77,7 +77,6 @@ async def execute_task(
explorer_flash_mode: Override Explorer version mode for Flash execution profile.
explorer_pro_mode: Override Explorer version mode for Pro execution profile.
"""
publish_startup_progress("configuration", "Loading the run configuration")
effective_sid = (
session_id
or os.getenv("ARTEMIS_SESSION_ID")
@@ -85,6 +84,9 @@ async def execute_task(
)
if effective_sid:
os.environ["ARTEMIS_SESSION_ID"] = str(effective_sid)
publish_startup_progress(
"configuration", "Loading the run configuration", session_id=str(effective_sid) if effective_sid else None
)
llm_config = initialize_llm_config()
agent_profile = AgentProfile(name="default", llm_config=llm_config)
@@ -142,13 +144,11 @@ async def execute_task(
agent: Agent | None = None
try:
publish_startup_progress("device_check", "Checking the Android device")
agent = Agent(config=config.build(), session_id=effective_sid)
await agent.init(
retry_count=int(os.getenv("ARTEMIS_HEALTH_RETRIES", 5)),
retry_wait_seconds=int(os.getenv("ARTEMIS_HEALTH_DELAY", 2)),
)
publish_startup_progress("device_ready", "Android device connected")
task = agent.new_task(goal)
if locked_app_package:
@@ -23,12 +23,21 @@ from typing import Annotated
import urllib.error
import urllib.request
from artemis.interfaces.cli.commands.server_lifecycle import (
restart_command,
status_command,
stop_command,
)
from artemis.utils.logger import get_logger
import typer
logger = get_logger(__name__)
server_app = typer.Typer(help="Cloud proxy and server management commands.")
server_app.command("restart", help="Restart running Artemis Web UI & server.")(restart_command)
server_app.command("stop", help="Stop running Artemis Web UI & server.")(stop_command)
server_app.command("status", help="Display Artemis Web UI & server status.")(status_command)
cached_token = ""
token_expire_time = 0.0
+6
View File
@@ -158,6 +158,9 @@ def submit_task_to_daemon(
enable_outputter: bool | None = None,
locked_app_package: str | None = None,
app_path: str | None = None,
session_id: str | None = None,
ingress: str = "client",
conversation_id: str | None = None,
base_url: str | None = None,
timeout: float = 5.0,
) -> dict[str, Any] | None:
@@ -174,6 +177,9 @@ def submit_task_to_daemon(
"enable_outputter": enable_outputter,
"locked_app_package": locked_app_package,
"app_path": app_path,
"session_id": session_id,
"ingress": ingress,
"conversation_id": conversation_id,
}
try:
+10
View File
@@ -223,6 +223,16 @@ class DevicePool:
# If not detected by ADB, still return it so ADB can attempt connection
return preferred_serial
# Prefer active ready device from readiness engine if available
try:
from artemis.core.diagnostics import readiness_engine
active_serial = readiness_engine.get_active_device_serial()
if active_serial and any(d.serial == active_serial and d.state == "device" for d in all_devs):
return active_serial
except Exception:
pass
# Pick first idle device
for dev in ready_devs:
if not dev.is_busy:
+6 -4
View File
@@ -203,6 +203,7 @@ class Agent:
raise DeviceNotFoundError(error_msg)
# Initialize clients
publish_startup_progress("device_check", "Checking the Android device", session_id=self._session_id)
if os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
self._init_clients(
device_id=device_id,
@@ -221,6 +222,7 @@ class Agent:
device_id=device_id, platform=platform
)
logger.info(self._device_context.to_str())
publish_startup_progress("device_ready", "Android device connected", session_id=self._session_id)
# Asynchronously pre-warm LLM connection pools in the background
asyncio.create_task(self._prewarm_llm_connections(api_key))
@@ -580,15 +582,15 @@ class Agent:
if os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
if self._ui_adb_client is not None:
publish_startup_progress(
"uiautomator", "Connecting to UI Automator", session_id=str(task.id)
"uiautomator", "Connecting to UI Automator", session_id=str(sess_id)
)
await asyncio.to_thread(self._ui_adb_client.connect)
publish_startup_progress(
"uiautomator_ready", "UI Automator is ready", session_id=str(task.id)
"uiautomator_ready", "UI Automator is ready", session_id=str(sess_id)
)
await self._ensure_device_unlocked()
publish_startup_progress(
"environment", "Preparing the device environment", session_id=str(task.id)
"environment", "Preparing the device environment", session_id=str(sess_id)
)
await self._prepare_app_installation(task=task)
await self._prepare_device_environment(context=context)
@@ -620,7 +622,7 @@ class Agent:
logger.error(f"[{task_name}] Failed to start screen recording: {e}")
publish_startup_progress(
"environment_ready", "Device environment is ready", session_id=str(task.id)
"environment_ready", "Device environment is ready", session_id=str(sess_id)
)
try:
if request.profile and request.profile.lower() == "flash":
+31 -68
View File
@@ -24,6 +24,7 @@ from PIL import Image, ImageDraw
from mcp_server.base import mcp
from mcp_server.utils import env_utils, trace_store
from artemis.utils.visualization import draw_action_overlay_on_image
from artemis.utils.task_tree import (
_render_step_detailed,
build_plan_and_history,
@@ -31,79 +32,41 @@ from artemis.utils.task_tree import (
def _draw_action_overlay(bg_image_path: str, action_obj: dict, output_path: str) -> bool:
"""Draws the action (tap, swipe, etc.) on the image and saves it."""
"""Draws the action overlay on the image using Artemis's native visualization engine."""
try:
with Image.open(bg_image_path) as img:
draw_img = img.convert("RGBA")
draw = ImageDraw.Draw(draw_img)
if not os.path.exists(bg_image_path):
return False
act_type = action_obj.get("action") or action_obj.get("name")
coords = action_obj.get("coordinates") or action_obj.get("target")
with open(bg_image_path, "rb") as f:
raw_bytes = f.read()
if not act_type or not coords:
return False
act_name = action_obj.get("action") or action_obj.get("name") or ""
action_args = dict(action_obj.get("args") or {})
for k in (
"target",
"coordinates",
"point",
"direction",
"sequence",
"targets",
"normalized_coordinates",
):
if k in action_obj and k not in action_args:
action_args[k] = action_obj[k]
fill_color = (255, 0, 0, 80)
outline_color = (255, 0, 0, 255)
outline_width = 4
annotated_bytes = draw_action_overlay_on_image(
image_bytes=raw_bytes,
action_name=act_name,
action_args=action_args,
)
if act_type in (
"tap",
"click",
"long_press",
"focus_and_input_text",
"focus_and_clear_text",
):
if isinstance(coords, list) and len(coords) == 2:
x, y = coords[0], coords[1]
r = 25
draw.ellipse(
[x - r, y - r, x + r, y + r],
fill=fill_color,
outline=outline_color,
width=outline_width,
)
r_center = 4
draw.ellipse(
[
x - r_center,
y - r_center,
x + r_center,
y + r_center,
],
fill=outline_color,
outline=outline_color,
)
elif act_type == "swipe":
if isinstance(coords, list) and len(coords) == 4:
x1, y1, x2, y2 = coords[0], coords[1], coords[2], coords[3]
draw.line([x1, y1, x2, y2], fill=outline_color, width=6)
r_start = 8
draw.ellipse(
[
x1 - r_start,
y1 - r_start,
x1 + r_start,
y1 + r_start,
],
fill=outline_color,
)
dx = x2 - x1
dy = y2 - y1
angle = math.atan2(dy, dx)
arrow_length = 30
arrow_angle = math.pi / 6
if not annotated_bytes or annotated_bytes == raw_bytes:
return False
p1_x = x2 - arrow_length * math.cos(angle - arrow_angle)
p1_y = y2 - arrow_length * math.sin(angle - arrow_angle)
p2_x = x2 - arrow_length * math.cos(angle + arrow_angle)
p2_y = y2 - arrow_length * math.sin(angle + arrow_angle)
draw.polygon([x2, y2, p1_x, p1_y, p2_x, p2_y], fill=outline_color)
final_img = draw_img.convert("RGB")
final_img.save(output_path, "JPEG", quality=85)
return True
os.makedirs(os.path.dirname(output_path), exist_ok=True)
with open(output_path, "wb") as f:
f.write(annotated_bytes)
return True
except Exception as e:
print(f"Error drawing action overlay: {e}", file=sys.stderr)
return False
@@ -434,7 +397,7 @@ async def mobile_inspect_trace(action: str, trace_id: str, step_number: int | No
action_obj[0] if isinstance(action_obj, list) and action_obj else action_obj
)
if isinstance(action_parsed, dict) and action_parsed.get("action"):
if isinstance(action_parsed, dict) and (action_parsed.get("action") or action_parsed.get("name")):
if not os.path.exists(overlay_abs_path):
success_draw = _draw_action_overlay(
path, action_parsed, overlay_abs_path
+51 -6
View File
@@ -162,16 +162,53 @@ def mobile_run_task(
device_serial=device_serial,
)
# 3. Auto-spawn daemon if not already running (unless standalone forced)
# 3. Dispatch via unified Artemis Daemon scheduler if available (unless standalone forced)
if os.environ.get("ARTEMIS_STANDALONE") != "1":
try:
from artemis.runtime import ensure_daemon_running
from artemis.runtime import ensure_daemon_running, submit_task_to_daemon
ensure_daemon_running(timeout=1.5)
except Exception:
pass
is_running, base_url = ensure_daemon_running(timeout=2.0, wait_ready=True)
if is_running:
resp = submit_task_to_daemon(
goal=task_desc,
profile=canonical_model.lower(),
device_serial=device_serial,
expected_output=expected_output_desc if canonical_model != "Flash" else None,
locked_app_package=locked_app_package,
app_path=app_path,
session_id=trace_id,
ingress="mcp",
conversation_id=conversation_id,
base_url=base_url,
)
if resp and resp.get("tasks"):
assigned_sid = resp["tasks"][0].get("session_id", trace_id)
trace_store.update_trace_status(
assigned_sid, "running", device_serial=device_serial
)
notes_dir = str(trace_store.get_trace_notes_dir(assigned_sid))
stdout_log = str(trace_store.get_trace_stdout_log_path(assigned_sid))
stderr_log = str(trace_store.get_trace_stderr_log_path(assigned_sid))
return {
"trace_id": assigned_sid,
"status": "running",
"message": (
f"Autonomous task '{task_desc}' enqueued via unified Artemis Daemon.\n"
f"Trace ID: {assigned_sid}\n"
f"Model: {canonical_model}\n"
f"Live telemetry: streaming to web workspace."
),
"notes_dir": notes_dir,
"stdout_log": stdout_log,
"stderr_log": stderr_log,
}
except Exception as exc:
import logging
logging.getLogger("mcp_server").warning(
f"Daemon dispatch failed, falling back to standalone runner: {exc}"
)
# 4. Resolve project root, python executable, and background runner module
# 4. Standalone Fallback: Resolve project root, python executable, and background runner module
project_root = env_utils.get_project_root()
python_exe = env_utils.resolve_python_executable(project_root)
reserve_kwargs: dict[str, Any] = {
@@ -214,6 +251,14 @@ def mobile_run_task(
env["ADB_DEVICE_SERIAL"] = device_serial
env["ARTEMIS_DEVICE_ID"] = device_serial
env[DeviceExecutionLock.QUEUE_TICKET_ENV] = queue_ticket
try:
from artemis.config.runtime import read_ipc_port
ipc_port = read_ipc_port()
if ipc_port:
env["ARTEMIS_IPC_PORT"] = str(ipc_port)
except Exception:
pass
proc_kwargs = env_utils.get_detached_process_kwargs()
proc = subprocess.Popen(
@@ -59,6 +59,33 @@ async def test_cancelled_event_stream_reaps_queue_waiter():
assert _pending_queue_get_tasks() == queue_tasks_before
@pytest.mark.asyncio
async def test_stream_events_active_session_replay():
"""Verify stream_events with default 'active' replays running session info and steps."""
state.is_shutting_down = False
state.shutdown_event.clear()
state.active_session_id = "replay-sess-123"
state.current_goal = "Replay Goal"
state.current_profile = "flash"
with patch("apps.admin_console.database.repositories.step_repository.step_repo") as mock_step_repo:
mock_step_repo.get_session_steps.return_value = [
{"step_number": 1, "description": "Step 1", "status": "completed"}
]
response = await stream_events()
iterator = response.body_iterator
first_event = await anext(iterator)
assert "Subscribed to session active" in first_event
second_event = await anext(iterator)
assert "session_started" in second_event
assert "replay-sess-123" in second_event
third_event = await anext(iterator)
assert "step_recorded" in third_event
assert "Step 1" in third_event
await iterator.aclose()
@pytest.mark.asyncio
async def test_windows_sigint_is_ignored_while_task_is_active():
state.current_process = MagicMock(returncode=None)
@@ -74,3 +74,41 @@ async def test_run_task_enqueues_when_device_is_unlocked(monkeypatch):
assert result["status"] == "started"
enqueue_tasks.assert_awaited_once()
@pytest.mark.asyncio
async def test_run_task_switches_to_unlocked_device_and_syncs_engine(monkeypatch):
"""When default device is locked, run_task should enqueue on verified fallback and update engine."""
unlocked_probe = ProbeResult(
id="android_adb",
category=ProbeCategory.DEVICE,
title="Device / Emulator Connected",
status=ProbeStatus.PASS,
is_blocker=True,
summary="Connected",
description="Ready.",
metadata={
"installed": True,
"submission_probe": True,
"active_device": {"serial": "emulator-5556", "is_locked": False},
},
)
run_probe = AsyncMock(return_value=unlocked_probe)
enqueue_tasks = AsyncMock(return_value={"status": "started", "tasks": []})
monkeypatch.setattr(
tasks.readiness_engine, "run_device_submission_probe", run_probe
)
monkeypatch.setattr(tasks.task_queue_service, "enqueue_tasks", enqueue_tasks)
monkeypatch.setattr(tasks.readiness_engine, "get_active_device_serial", lambda: "device-locked-1")
set_active_mock = AsyncMock()
monkeypatch.setattr(tasks.readiness_engine, "set_active_device_serial", set_active_mock)
result = await tasks.run_task(RunRequest(goal="Run on any ready device"))
assert result["status"] == "started"
enqueue_tasks.assert_awaited_once()
# Check that device_serial passed to enqueue_tasks is the verified unlocked device
_, kwargs = enqueue_tasks.call_args
assert kwargs.get("device_serial") == "emulator-5556"
set_active_mock.assert_called_once_with("emulator-5556")
@@ -607,3 +607,198 @@ def test_darwin_terminate_process_tree_preserves_direct_child_for_asyncio():
for call_args in mock_wait_procs.call_args_list:
procs_waited = call_args[0][0]
assert parent not in procs_waited
@pytest.mark.asyncio
async def test_enqueue_tasks_unified_ingress():
"""Verify enqueue_tasks correctly propagates ingress, custom session_id, and conversation_id."""
with patch.object(TaskQueueService, "ensure_worker_running"):
res = await task_queue_service.enqueue_tasks(
["Test unified goal"],
profile="flash",
ingress="mcp",
session_id="custom-mcp-session-123",
conversation_id="conv-456",
)
assert res["status"] in ("started", "queued")
assert len(res["tasks"]) == 1
task = res["tasks"][0]
assert task["session_id"] == "custom-mcp-session-123"
assert task["ingress"] == "mcp"
assert task["conversation_id"] == "conv-456"
assert task["goal"] == "Test unified goal"
@pytest.mark.asyncio
async def test_queue_worker_notifies_conversation():
"""Verify queue_worker calls notify() when conversation_id is attached to task."""
executed_cmds = []
async def fake_subprocess_exec(*args, **kwargs):
executed_cmds.append(list(args))
proc = MagicMock()
proc.pid = 99999
proc.wait = AsyncMock(return_value=0)
proc.returncode = 0
return proc
with (
patch("asyncio.create_subprocess_exec", side_effect=fake_subprocess_exec),
patch("apps.admin_console.services.task_queue_service.media_service"),
patch("mcp_server.notifiers.notify") as mock_notify,
patch("apps.admin_console.services.task_queue_service.session_repo") as mock_repo,
):
mock_repo.get_running_session_id.return_value = None
await task_queue_service.enqueue_tasks(
["Notify goal"],
profile="flash",
ingress="mcp",
conversation_id="conv-789",
device_serial="test-mock-serial",
)
for _ in range(30):
if mock_notify.called and len(state.queue_items) == 0:
break
await asyncio.sleep(0.05)
mock_notify.assert_called_once()
kwargs = mock_notify.call_args[1]
assert kwargs["conversation_id"] == "conv-789"
assert "Notify goal" in kwargs["message"]
task = state.worker_task
if task and not task.done():
task.cancel()
try:
await task
except (asyncio.CancelledError, Exception):
pass
def test_stop_tasks_by_session_id_targets_correct_task_among_multiple():
"""Stopping by session_id terminates only the targeted owner among multiple concurrent devices."""
owner_a = DeviceLockOwner(
pid=11111,
process_created_at=100.0,
token="token-a",
device_id="device-a",
description="Task on device A",
acquired_at="2026-08-24T00:00:00+00:00",
session_id="session-a",
ingress="cli",
)
owner_b = DeviceLockOwner(
pid=22222,
process_created_at=200.0,
token="token-b",
device_id="device-b",
description="Task on device B",
acquired_at="2026-08-24T00:00:00+00:00",
session_id="session-b",
ingress="mcp",
)
with (
patch(
"apps.admin_console.services.task_queue_service.DeviceExecutionLock.get_active_owners",
return_value={"device_a": owner_a, "device_b": owner_b},
),
patch(
"apps.admin_console.services.task_queue_service.DeviceExecutionLock.is_active_owner",
return_value=True,
),
patch(
"apps.admin_console.services.task_queue_service.DeviceExecutionLock.cleanup_stale_locks"
) as cleanup_locks,
patch(
"apps.admin_console.services.task_queue_service.process_supervisor.terminate_tree_verified",
return_value=True,
) as terminate_verified,
patch(
"apps.admin_console.services.task_queue_service.session_repo.update_session_status"
) as update_status,
patch("mcp_server.utils.trace_store.update_trace_status") as update_trace,
):
# Explicitly stop session-b
assert task_queue_service.stop_tasks(clear_all=False, session_id="session-b") is True
# Only session-b's process (22222) should be terminated
terminate_verified.assert_called_once_with(22222, 200.0)
cleanup_locks.assert_called_once_with("device-b")
update_status.assert_called_once()
assert update_status.call_args[0][0] == "session-b"
assert update_status.call_args[0][1] == "cancelled"
update_trace.assert_called_once_with(
"session-b",
"cancelled",
error="Task stopped from the Artemis frontend.",
)
def test_stop_tasks_by_device_id_targets_specific_device():
"""Stopping by device_id targets the active task on that specific device."""
owner_a = DeviceLockOwner(
pid=33333,
process_created_at=300.0,
token="token-a",
device_id="pixel-8",
description="Task on Pixel 8",
acquired_at="2026-08-24T00:00:00+00:00",
session_id="session-pixel8",
ingress="sdk",
)
with (
patch(
"apps.admin_console.services.task_queue_service.DeviceExecutionLock.get_active_owners",
return_value={"pixel_8": owner_a},
),
patch(
"apps.admin_console.services.task_queue_service.DeviceExecutionLock.is_active_owner",
return_value=True,
),
patch("apps.admin_console.services.task_queue_service.DeviceExecutionLock.cleanup_stale_locks"),
patch(
"apps.admin_console.services.task_queue_service.process_supervisor.terminate_tree_verified",
return_value=True,
) as terminate_verified,
patch("apps.admin_console.services.task_queue_service.session_repo.update_session_status"),
):
assert task_queue_service.stop_tasks(clear_all=False, device_id="pixel-8") is True
terminate_verified.assert_called_once_with(33333, 300.0)
def test_stop_tasks_queued_item_by_session_id():
"""Stopping a queued task by session_id cancels its reservation and removes it from queue."""
state.queue_items = [
{"session_id": "queue-1", "goal": "Goal 1", "status": "pending", "queue_ticket": "ticket-1"},
{"session_id": "queue-2", "goal": "Goal 2", "status": "pending", "queue_ticket": "ticket-2"},
]
with (
patch(
"apps.admin_console.services.task_queue_service.DeviceExecutionLock.get_active_owners",
return_value={},
),
patch(
"apps.admin_console.services.task_queue_service.DeviceExecutionLock.get_active_owner",
return_value=None,
),
patch(
"apps.admin_console.services.task_queue_service.DeviceExecutionLock.cancel_reservation",
return_value=True,
) as cancel_res,
patch("apps.admin_console.services.task_queue_service.session_repo.update_session_status") as update_status,
):
assert task_queue_service.stop_tasks(clear_all=False, session_id="queue-1") is True
cancel_res.assert_called_once_with("ticket-1")
# queue-2 should remain in queue
assert len(state.queue_items) == 1
update_status.assert_called_once()
assert update_status.call_args[0][0] == "queue-1"
assert update_status.call_args[0][1] == "cancelled"
+49
View File
@@ -313,6 +313,55 @@ async def test_submission_probe_fails_closed_when_lock_state_is_unknown(monkeypa
assert result.status == ProbeStatus.WARN
@pytest.mark.asyncio
async def test_submission_probe_falls_back_to_unlocked_device(monkeypatch):
"""When the first device is locked but a second device is unlocked, submission probe falls back."""
probe = AdbDeviceProbe()
monkeypatch.setattr(
probe,
"_get_device_states",
AsyncMock(return_value=[("device-locked", "device"), ("device-unlocked", "device")]),
)
async def mock_lock_state(adb_path, serial, timeout_seconds=1.0):
return True if serial == "device-locked" else False
monkeypatch.setattr(probe, "_get_confirmed_device_lock_state", mock_lock_state)
result = await probe.probe_submission_readiness()
assert result.status == ProbeStatus.PASS
assert result.summary == "Connected"
assert result.metadata["active_device"]["serial"] == "device-unlocked"
assert result.metadata["active_device"]["is_locked"] is False
@pytest.mark.asyncio
async def test_adb_probe_prefers_unlocked_device_when_one_is_locked(monkeypatch):
"""When multiple ready devices exist, probe() should pick the unlocked one as active."""
from artemis.core.diagnostics.schema import DeviceInfo
probe = AdbDeviceProbe()
monkeypatch.setattr(probe, "_locate_adb", lambda: "/usr/bin/adb")
monkeypatch.setattr(probe, "_get_adb_version", AsyncMock(return_value="1.0.41"))
monkeypatch.setattr(probe, "_locate_emulator", lambda: "/usr/bin/emulator")
monkeypatch.setattr(probe, "_list_installed_avds", lambda _: [])
devices = [
DeviceInfo(serial="dev-locked-1", state="device", model="Pixel 7", is_locked=True),
DeviceInfo(serial="dev-unlocked-2", state="device", model="Pixel 8", is_locked=False),
]
monkeypatch.setattr(probe, "_parse_adb_devices", AsyncMock(return_value=devices))
result = await probe.probe()
assert result.status == ProbeStatus.PASS
assert result.summary == "Connected"
assert result.metadata["active_device"]["serial"] == "dev-unlocked-2"
assert result.metadata["active_device"]["is_locked"] is False
@pytest.mark.asyncio
async def test_llm_credentials_probe_structure():
"""Verify LLMCredentialsProbe returns correct category and schema."""
+16 -1
View File
@@ -12,6 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import json
from unittest.mock import MagicMock, patch
import pytest
@@ -92,12 +93,26 @@ def test_submit_task_to_daemon():
mock_resp.read.return_value = b'{"status": "queued", "tasks": [{"session_id": "test-sess"}]}'
mock_resp.__enter__.return_value = mock_resp
with patch("urllib.request.urlopen", return_value=mock_resp):
with patch("urllib.request.urlopen", return_value=mock_resp) as mock_urlopen:
res = submit_task_to_daemon(
"test goal",
profile="flash",
device_serial="pixel-8",
session_id="custom-sess-789",
ingress="mcp",
conversation_id="conv-123",
)
assert res is not None
assert res["status"] == "queued"
assert res["tasks"][0]["session_id"] == "test-sess"
# Verify request payload
req = mock_urlopen.call_args[0][0]
data = json.loads(req.data.decode("utf-8"))
assert data["goal"] == "test goal"
assert data["profile"] == "flash"
assert data["device_serial"] == "pixel-8"
assert data["session_id"] == "custom-sess-789"
assert data["ingress"] == "mcp"
assert data["conversation_id"] == "conv-123"
+19
View File
@@ -436,3 +436,22 @@ def test_cli_restart_command(monkeypatch):
assert stopped["port"] == 8888
assert ui_called["port"] == 8888
assert ui_called["open_browser"] is False
def test_cli_server_lifecycle_aliases(monkeypatch):
"""Verify 'artemis server status/stop/restart' subcommands are accessible."""
from artemis.runtime import server_lifecycle
monkeypatch.setattr(server_lifecycle, "is_port_in_use", lambda port, **kwargs: False)
monkeypatch.setattr(server_lifecycle, "find_server_pids", lambda port: [])
monkeypatch.setattr(server_lifecycle, "read_server_info", lambda: None)
result = runner.invoke(app, ["server", "status", "--port", "59998"])
assert result.exit_code == 0
assert "OFFLINE" in result.output or "STOPPED" in result.output
help_result = runner.invoke(app, ["server", "--help"])
assert help_result.exit_code == 0
assert "restart" in help_result.output
assert "stop" in help_result.output
assert "status" in help_result.output
+11 -6
View File
@@ -16,7 +16,7 @@
from pathlib import Path
import tempfile
from unittest.mock import MagicMock
from unittest.mock import MagicMock, patch
import pytest
from pydantic import SecretStr
@@ -218,8 +218,12 @@ def test_output_config_and_recording():
def test_runtime_state_and_ipc(tmp_path, monkeypatch):
"""Test IPC port and LS address state helpers."""
import urllib.request
monkeypatch.setattr(urllib.request, "urlopen", MagicMock(side_effect=Exception("offline")))
ipc_state_file = tmp_path / ".artemis_ipc_port"
monkeypatch.setattr("artemis.config.runtime.get_ipc_port_file", lambda: ipc_state_file)
monkeypatch.setattr("artemis.config.runtime.ROOT_DIR", tmp_path)
monkeypatch.setattr("artemis.config.runtime.get_app_dir", lambda: tmp_path)
# Test IPC port
write_ipc_port(49152)
assert read_ipc_port() == 49152
@@ -365,8 +369,8 @@ def test_checker_builder_and_context_propagation():
mock_task.request.record_trace = False
mock_task.request.name = "test_task"
mock_task.request.profile = None
mock_task.request.goal = "Test goal"
agent._prepare_tracing(mock_task, ctx)
with patch("artemis.sdk.agent.DataEngine"):
agent._prepare_tracing(mock_task, ctx)
assert ctx.execution_setup is not None
assert ctx.execution_setup.disable_checker is False
@@ -455,8 +459,8 @@ def test_explorer_builder_and_resolution(monkeypatch):
mock_task.request.record_trace = False
mock_task.request.name = "explorer_test_task"
mock_task.request.profile = None
mock_task.request.goal = "Test explorer propagation"
agent._prepare_tracing(mock_task, ctx)
with patch("artemis.sdk.agent.DataEngine"):
agent._prepare_tracing(mock_task, ctx)
assert ctx.execution_setup is not None
assert ctx.execution_setup.explorer_version == "pro"
@@ -505,7 +509,8 @@ def test_outputter_builder_and_context_propagation():
mock_task.request.name = "outputter_test_task"
mock_task.request.profile = None
mock_task.request.goal = "Test outputter propagation"
agent._prepare_tracing(mock_task, ctx)
with patch("artemis.sdk.agent.DataEngine"):
agent._prepare_tracing(mock_task, ctx)
assert ctx.execution_setup is not None
assert ctx.execution_setup.disable_outputter is False