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

+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);
}
}