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
@@ -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