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:
1 parent
57655266cc
commit
8fdecf23d7
33 files changed
+1315
-293
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user