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

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

No files matched your search

@@ -74,6 +74,16 @@
</span>
<span class="item-time-divider" *ngIf="getDeviceSerial(session)">·</span>
<span class="item-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
<button
*ngIf="getTaskStatus(session) === 'running' || getTaskStatus(session) === 'paused'"
type="button"
class="btn-stop-card"
(click)="stopTask(session.session_id, $event)"
title="Stop this task"
aria-label="Stop this task"
>
<span class="material-symbols-outlined">stop_circle</span>
</button>
<button
*ngIf="getTaskStatus(session) !== 'running' && getTaskStatus(session) !== 'paused'"
type="button"
@@ -551,11 +551,14 @@ $stream-row-height: 25px;
.item-header .item-header-actions {
&:has(.btn-delete-card) .item-time,
&:has(.btn-delete-card) .item-time-divider {
&:has(.btn-delete-card) .item-time-divider,
&:has(.btn-stop-card) .item-time,
&:has(.btn-stop-card) .item-time-divider {
opacity: 0;
}
.btn-delete-card {
.btn-delete-card,
.btn-stop-card {
opacity: 1;
pointer-events: auto;
}
@@ -756,6 +759,38 @@ $stream-row-height: 25px;
background: #fee2e2;
}
}
.btn-stop-card {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
width: 18px;
height: 18px;
padding: 0;
border: none;
background: transparent;
border-radius: 3px;
color: #ef4444;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: none;
transition: all 0.15s ease;
.material-symbols-outlined {
font-size: 14px;
line-height: 1;
}
&:hover {
color: #dc2626;
background: #fee2e2;
transform: translateY(-50%) scale(1.1);
}
}
}
}
@@ -643,6 +643,11 @@ export class AgentStreamComponent implements AfterViewInit {
this.isTaskDropdownOpen.set(false);
}
public stopTask(sessionId: string, event?: Event): void {
if (event) event.stopPropagation();
this.agentService.stopTask(sessionId, false);
}
public deleteTask(sessionId: string, event?: Event): void {
if (event) event.stopPropagation();
if (!confirm(`Are you sure you want to delete this task? This cannot be undone.`)) {
@@ -83,6 +83,16 @@
</span>
<span class="task-time-divider" *ngIf="getDeviceSerial(session)">·</span>
<span class="task-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
<button
*ngIf="getTaskStatus(session) === 'running' || getTaskStatus(session) === 'paused'"
type="button"
class="btn-stop-card"
(click)="stopTask(session.session_id, $event)"
title="Stop this task"
aria-label="Stop this task"
>
<span class="material-symbols-outlined">stop_circle</span>
</button>
<button
*ngIf="getTaskStatus(session) !== 'running' && getTaskStatus(session) !== 'paused'"
type="button"
@@ -297,11 +297,14 @@ $text-muted: #a1a1aa;
.task-card-header .task-card-header-actions {
&:has(.btn-delete-card) .task-time,
&:has(.btn-delete-card) .task-time-divider {
&:has(.btn-delete-card) .task-time-divider,
&:has(.btn-stop-card) .task-time,
&:has(.btn-stop-card) .task-time-divider {
opacity: 0;
}
.btn-delete-card {
.btn-delete-card,
.btn-stop-card {
opacity: 1;
pointer-events: auto;
}
@@ -508,6 +511,43 @@ $text-muted: #a1a1aa;
cursor: not-allowed;
}
}
.btn-stop-card {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
width: 20px;
height: 20px;
padding: 0;
border: none;
background: transparent;
border-radius: 4px;
color: #ef4444;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: none;
transition: all 0.15s ease;
.material-symbols-outlined {
font-size: 15px;
line-height: 1;
}
&:hover:not(:disabled) {
color: #dc2626;
background: #fee2e2;
transform: translateY(-50%) scale(1.1);
}
&:disabled {
opacity: 0.3;
cursor: not-allowed;
}
}
}
}
@@ -98,6 +98,19 @@ export class ChatInterfaceComponent {
});
}
/**
* Stop an individual task session
*/
public stopTask(sessionId: string, event: MouseEvent): void {
event.stopPropagation();
this.isSubmitting = true;
this.errorMessage = null;
this.agentService.stopTask(sessionId, false);
setTimeout(() => {
this.isSubmitting = false;
}, 400);
}
/**
* Stop the currently running task and optionally clear the backend queue
*/
@@ -1375,6 +1375,31 @@
<span>{{ isRefreshingDiagnostics() ? 'Checking...' : 'Check Again' }}</span>
</button>
</div>
<!-- Multi-Device Switcher if multiple devices detected -->
@if (connectedDevices().length > 1) {
<div class="multi-device-switcher" style="margin-top: 12px;">
<span class="switcher-label">Select Active Device:</span>
<div class="switcher-chips">
@for (dev of connectedDevices(); track dev.serial) {
<button
type="button"
class="device-chip-btn"
[class.selected]="dev.serial === activeDevice()?.serial"
(click)="selectTargetDevice(dev.serial)"
>
<span class="material-symbols-outlined chip-icon">
phone_android
</span>
<span>{{ dev.model || dev.serial }}</span>
@if (dev.serial === activeDevice()?.serial) {
<span class="active-check">✓ Active</span>
}
</button>
}
</div>
</div>
}
</div>
}
@@ -116,11 +116,11 @@
<span class="material-symbols-outlined">close</span>
</button>
<!-- Stop Button (Visible when task is running) -->
<!-- Stop Button (Visible when currently viewed task is active) -->
<button *ngIf="isTaskRunning()" type="button" class="dock-circle-btn btn-stop" [disabled]="isSubmitting"
(click)="stopTask($event)"
title="Stop running task"
aria-label="Stop running task"
[title]="stopButtonTitle()"
[attr.aria-label]="stopButtonTitle()"
>
<span class="material-symbols-outlined">stop</span>
</button>
@@ -161,12 +161,12 @@
</div>
</div>
<!-- Separate Standalone Stop Button Beside the Capsule (Visible when task is running) -->
<!-- Separate Standalone Stop Button Beside the Capsule (Visible when currently viewed task is active) -->
<button *ngIf="isTaskRunning()" type="button" class="standalone-stop-btn"
[disabled]="isSubmitting"
(click)="stopTask($event)"
title="Stop running task"
aria-label="Stop running task"
[title]="stopButtonTitle()"
[attr.aria-label]="stopButtonTitle()"
>
<span class="material-symbols-outlined">stop</span>
</button>
@@ -77,10 +77,29 @@ export class WorkspaceComponent implements OnInit {
}
/**
* Computed boolean whether a task is currently executing or active
* Computed boolean whether the currently viewed task is actively running or paused.
* Only displays the stop/cancel button when inspecting an active task.
*/
public isTaskRunning = computed(() => {
return this.agentService.isRunningTask();
return this.agentService.isCurrentSessionRunning();
});
/**
* Dynamic tooltip and label indicating which task will be stopped
*/
public stopButtonTitle = computed(() => {
const session = this.agentService.currentSession();
if (session?.initial_goal) {
const truncated = session.initial_goal.length > 45
? session.initial_goal.substring(0, 42) + '...'
: session.initial_goal;
return `Stop current task: "${truncated}"`;
}
const curId = this.agentService.currentSessionId();
if (curId) {
return `Stop current task (${curId})`;
}
return 'Stop current running task';
});
/**
@@ -216,7 +235,7 @@ export class WorkspaceComponent implements OnInit {
}
/**
* Stop currently running task
* Stop currently viewed task
*/
public stopTask(event?: MouseEvent): void {
if (event) {
@@ -225,9 +244,10 @@ export class WorkspaceComponent implements OnInit {
if (!this.isTaskRunning()) {
return;
}
const targetSessionId = this.agentService.currentSessionId();
this.isSubmitting = true;
this.errorMessage = null;
this.agentService.stopTask(false);
this.agentService.stopTask(targetSessionId, false);
setTimeout(() => {
this.isSubmitting = false;
}, 400);
@@ -275,3 +275,108 @@ describe('AgentService video analysis seeking', () => {
expect(second?.requestId).toBeGreaterThan(first?.requestId || 0);
});
});
describe('AgentService task cancellation and active session tracking', () => {
it('computes isCurrentSessionRunning true only when viewing an active running/paused session', () => {
const service = Object.create(AgentService.prototype) as any;
service.currentSessionId = signal<string | null>(null);
service.rawSessions = signal<any[]>([]);
service.activeTasks = signal<any[]>([]);
service.pendingQueue = signal<any[]>([]);
service.agentStatus = signal('idle');
service.runningSessionId = signal<string | null>(null);
service.runningGoal = signal<string | null>(null);
service.activeSessionTracking = new Map();
service.currentSession = computed(() => {
const curId = service.currentSessionId();
if (!curId) return null;
return service.sessions().find((s: any) => s.session_id === curId) || null;
});
service.sessions = computed(() => {
return service.rawSessions();
});
service.isCurrentSessionRunning = computed(() => {
const curId = service.currentSessionId();
if (!curId) return false;
const session = service.currentSession();
if (session) {
return session.status === 'running' || session.status === 'paused';
}
const isActiveStatus = service.agentStatus() === 'running' || service.agentStatus() === 'paused';
if (isActiveStatus && service.runningSessionId() === curId) {
return true;
}
if (service.activeTasks().some((at: any) => at.session_id === curId)) {
return true;
}
return false;
});
// 1. No session selected -> false
expect(service.isCurrentSessionRunning()).toBeFalse();
// 2. Completed session selected -> false
service.rawSessions.set([
{ session_id: 'sess-completed', status: 'completed', initial_goal: 'Done', start_time: 100 },
{ session_id: 'sess-running', status: 'running', initial_goal: 'Running', start_time: 101 }
]);
service.currentSessionId.set('sess-completed');
expect(service.isCurrentSessionRunning()).toBeFalse();
// 3. Active running session selected -> true
service.currentSessionId.set('sess-running');
expect(service.isCurrentSessionRunning()).toBeTrue();
// 4. Paused session selected -> true
service.rawSessions.set([
{ session_id: 'sess-paused', status: 'paused', initial_goal: 'Paused', start_time: 102 }
]);
service.currentSessionId.set('sess-paused');
expect(service.isCurrentSessionRunning()).toBeTrue();
});
it('stops a specific session by passing its session_id', () => {
const service = Object.create(AgentService.prototype) as AgentService;
service.currentSessionId = signal<string | null>('sess-2');
service.runningSessionId = signal<string | null>('sess-1');
service.runningGoal = signal<string | null>('Goal 1');
service.agentStatus = signal('running');
service.isPaused = signal(false);
service.pausedError = signal<string | null>(null);
service.isRetrying = signal(false);
service.sessionLogs = signal<any[]>([]);
service.activeTasks = signal<any[]>([{ session_id: 'sess-1' }, { session_id: 'sess-2' }]);
(service as any).pendingQueue = signal<any[]>([]);
(service as any).rawSessions = signal<any[]>([
{ session_id: 'sess-1', status: 'running', initial_goal: 'Goal 1' },
{ session_id: 'sess-2', status: 'running', initial_goal: 'Goal 2' }
]);
service.sessions = computed(() => (service as any).rawSessions());
let postedUrl = '';
let postedPayload: any = null;
(service as any).http = {
post: (url: string, payload: any) => {
postedUrl = url;
postedPayload = payload;
return of({});
}
};
(service as any).setSessionStatus = (sid: string, st: string) => {};
(service as any).fetchStatus = () => {};
(service as any).fetchSessions = () => {};
// Stop sess-2 specifically
service.stopTask('sess-2', false);
expect(postedUrl).toContain('session_id=sess-2');
expect(postedPayload?.session_id).toBe('sess-2');
// Because sess-1 is still running, agentStatus should NOT be reset to idle
expect(service.agentStatus()).toBe('running');
// activeTasks should have filtered out sess-2
expect(service.activeTasks().map((at: any) => at.session_id)).toEqual(['sess-1']);
});
});
+182 -130
View File
@@ -275,12 +275,25 @@ export class AgentService {
});
/**
* Computed whether the currently viewed session is running
* Computed whether the currently viewed session is actively running or paused.
* Returns false when viewing completed/failed/cancelled tasks, or when no session is selected.
*/
public isCurrentSessionRunning = computed(() => {
const curId = this.currentSessionId();
if (!curId) return false;
const session = this.currentSession();
if (session) return session.status === 'running' || session.status === 'paused';
return this.agentStatus() === 'running' || this.agentStatus() === 'paused';
if (session) {
return session.status === 'running' || session.status === 'paused';
}
// Fallback if session was just submitted/selected but not yet merged into sessions list
const isActiveStatus = this.agentStatus() === 'running' || this.agentStatus() === 'paused';
if (isActiveStatus && this.runningSessionId() === curId) {
return true;
}
if (this.activeTasks().some((at) => at.session_id === curId)) {
return true;
}
return false;
});
/**
@@ -312,6 +325,7 @@ export class AgentService {
this.restoreSessionsCache();
this.fetchSessions();
this.startStatusPolling();
this.ensureLiveStream();
}
/**
@@ -382,43 +396,86 @@ export class AgentService {
}
/**
* Stop the currently running task (and optionally clear queue)
* Stop the currently viewed task (or specified task), and optionally clear queue.
* Supports stopping any task across any ingress/platform (Web, CLI, MCP, SDK).
*/
public stopTask(stopAll: boolean = false): void {
const stoppingSessionId = this.runningSessionId()
|| this.sessions().find((session) => session.status === 'running' || session.status === 'paused')?.session_id
|| null;
public stopTask(targetOrStopAll?: string | boolean | null, stopAll: boolean = false): void {
let targetSessionId: string | null = null;
let effectiveStopAll = stopAll;
if (typeof targetOrStopAll === 'boolean') {
effectiveStopAll = targetOrStopAll;
} else if (typeof targetOrStopAll === 'string' && targetOrStopAll.trim()) {
targetSessionId = targetOrStopAll.trim();
} else {
// Default to currently viewed session, or currently running session
targetSessionId = this.currentSessionId() || this.runningSessionId() || null;
}
if (!effectiveStopAll && !targetSessionId) {
targetSessionId = this.sessions().find(
(session) => session.status === 'running' || session.status === 'paused'
)?.session_id || null;
}
// Keep the task's terminal state stable while the stop request and the
// persisted session update cross the network. Clearing the global runner
// state first used to make the session merger infer "completed" from the
// still-stale DB row, causing a brief completed -> cancelled flicker.
this.setSessionStatus(stoppingSessionId, 'cancelled');
if (targetSessionId) {
this.setSessionStatus(targetSessionId, 'cancelled');
}
// Apply optimistic updates: only set idle if stopAll is true or pending queue is empty
if (stopAll || this.pendingQueue().length === 0) {
// Determine if other active sessions are still executing across multiple devices
const otherRunningSessions = this.sessions().filter(
(s) => (s.status === 'running' || s.status === 'paused') && s.session_id !== targetSessionId
);
// Apply optimistic updates: only set idle if effectiveStopAll is true or no other tasks are running
if (effectiveStopAll || otherRunningSessions.length === 0) {
this.agentStatus.set('idle');
this.runningSessionId.set(null);
this.runningGoal.set(null);
} else if (this.runningSessionId() === targetSessionId) {
this.runningSessionId.set(otherRunningSessions[0].session_id);
this.runningGoal.set(otherRunningSessions[0].initial_goal || null);
}
this.isPaused.set(false);
this.pausedError.set(null);
this.isRetrying.set(false);
if (stopAll) {
if (effectiveStopAll) {
this.pendingQueue.set([]);
}
// Mark active streaming / pending logs in current session as finished
this.sessionLogs.update((logs) =>
logs.map((l) => {
if (l.type === 'llm_stream' && !l.data?.isCompleted) {
return { ...l, data: { ...l.data, isCompleted: true } };
}
return l;
})
);
// Mark active streaming / pending logs as finished if viewing target session
if (!targetSessionId || this.currentSessionId() === targetSessionId) {
this.sessionLogs.update((logs) =>
logs.map((l) => {
if (l.type === 'llm_stream' && !l.data?.isCompleted) {
return { ...l, data: { ...l.data, isCompleted: true } };
}
return l;
})
);
}
this.http.post<any>(`/api/stop?all=${stopAll}`, {}).subscribe({
// Optimistically remove from activeTasks
if (targetSessionId) {
this.activeTasks.update((list) => list.filter((at) => at.session_id !== targetSessionId));
}
let url = `/api/stop?all=${effectiveStopAll}`;
if (!effectiveStopAll && targetSessionId) {
url += `&session_id=${encodeURIComponent(targetSessionId)}`;
}
const payload: any = { all: effectiveStopAll };
if (targetSessionId) {
payload.session_id = targetSessionId;
}
this.http.post<any>(url, payload).subscribe({
next: () => {
this.fetchStatus();
this.fetchSessions();
@@ -577,10 +634,6 @@ export class AgentService {
this.currentSessionId.set(null);
this.sessionLogs.set([]);
this.currentNotes.set({});
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
return;
}
@@ -605,37 +658,36 @@ export class AgentService {
this.openVideoPlayer(sessionId);
}
// Clean up previous event stream connection if any
console.log(`Selecting session: ${sessionId}`);
// Start reading the persisted snapshot immediately
this.backfillSessionSteps(sessionId, loadGeneration);
this.ensureLiveStream();
}
/**
* Ensure persistent single-channel live stream is established via /api/stream.
* Eliminates reconnect tearing, race conditions, and disconnect dropouts.
*/
public ensureLiveStream(): void {
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
return;
}
console.log(`Connecting to session: ${sessionId}`);
// Start reading the persisted snapshot immediately. Previously this waited
// for the SSE `info` event, adding connection latency to every history click.
this.backfillSessionSteps(sessionId, loadGeneration);
console.log('Establishing persistent unified live stream via /api/stream');
this.eventSource = new EventSource('/api/stream');
this.eventSource = new EventSource(`/api/stream/${sessionId}`);
// Active runs get one reconciliation snapshot after the stream subscribes,
// closing the snapshot-to-stream gap without delaying the first paint.
const selectedStatus = this.sessions().find((session) => session.session_id === sessionId)?.status;
const shouldReconcileAfterSubscribe = selectedStatus === 'running'
|| selectedStatus === 'paused'
|| selectedStatus === 'pending';
this.eventSource.addEventListener('info', () => {
if (shouldReconcileAfterSubscribe) {
this.backfillSessionSteps(sessionId, loadGeneration);
// Reconcile current session if active
const curId = this.currentSessionId();
if (curId) {
this.backfillSessionSteps(curId);
}
});
// Listen to standard SSE keep-alive
this.eventSource.addEventListener('keep-alive', () => {
// No-op keep-alive tick
// Keep-alive tick
});
// Listen to raw events and append them directly to the logs array
const eventTypes = [
'llm_stream',
'trace_recorded',
@@ -656,8 +708,6 @@ export class AgentService {
this.eventSource?.addEventListener(eventType, (event: MessageEvent) => {
try {
const parsedData = JSON.parse(event.data);
// If the event carries a session_id for a different session, update global states if applicable and ignore
const evtSessionId = parsedData?.session_id;
if (eventType === 'recording_ready' || eventType === 'recording_failed') {
@@ -682,50 +732,8 @@ export class AgentService {
return;
}
const isDifferentSession = evtSessionId
&& String(evtSessionId).trim().toLowerCase() !== String(sessionId).trim().toLowerCase();
if (isDifferentSession) {
if (eventType === 'session_started') {
this.agentStatus.set('running');
this.runningSessionId.set(parsedData.session_id);
if (parsedData?.initial_goal) {
this.runningGoal.set(parsedData.initial_goal);
}
this.fetchSessions();
// Only auto-select if user has no current session at all
const currentId = this.currentSessionId();
if (!currentId && parsedData?.session_id) {
this.selectSession(parsedData.session_id, false);
}
} else if (eventType === 'session_ended') {
this.applySessionEndedStatus(evtSessionId, parsedData);
this.activeTasks.update(list => list.filter(at => at.session_id !== evtSessionId));
const remaining = this.activeTasks();
if (remaining.length > 0) {
if (this.runningSessionId() === evtSessionId) {
this.runningSessionId.set(remaining[0].session_id);
this.runningGoal.set(remaining[0].goal || null);
}
} else if (this.pendingQueue().length > 0) {
const nextPending = this.pendingQueue()[0];
this.agentStatus.set('running');
this.runningSessionId.set(nextPending.session_id);
this.runningGoal.set(nextPending.initial_goal || null);
} else {
this.agentStatus.set('idle');
this.runningSessionId.set(null);
this.runningGoal.set(null);
}
this.fetchStatus();
this.fetchSessions();
this.updateModelForCurrentView();
}
return;
}
// Guard against obsolete subscriptions
if (this.currentSessionId() !== sessionId) {
if (eventType === 'background_tasks_updated') {
this.fetchStatus();
return;
}
@@ -738,12 +746,20 @@ export class AgentService {
this.runningGoal.set(parsedData.initial_goal);
}
this.fetchSessions();
// Auto-follow: if user has not explicitly pinned a historical session, switch view to new active session
if (!this.userPinnedSessionId() && parsedData?.session_id) {
this.selectSession(parsedData.session_id, false);
}
return;
}
if (eventType === 'session_ended') {
this.applySessionEndedStatus(sessionId, parsedData);
this.activeTasks.update(list => list.filter(at => at.session_id !== sessionId));
const endedId = evtSessionId || this.runningSessionId();
if (endedId) {
this.applySessionEndedStatus(endedId, parsedData);
this.activeTasks.update(list => list.filter(at => at.session_id !== endedId));
}
const remaining = this.activeTasks();
if (remaining.length > 0) {
this.agentStatus.set('running');
@@ -763,20 +779,40 @@ export class AgentService {
this.isPaused.set(false);
this.fetchSessions();
this.fetchStatus();
this.fetchNotes(sessionId);
this.updateModelForCurrentView();
if (
this.isVideoWindowOpen()
&& this.activeVideoSessionId === sessionId
&& this.recordingPlaybackStatus() === 'live'
) {
this.beginRecordingFinalization(sessionId);
if (endedId) {
this.fetchNotes(endedId);
if (
this.isVideoWindowOpen()
&& this.activeVideoSessionId === endedId
&& this.recordingPlaybackStatus() === 'live'
) {
this.beginRecordingFinalization(endedId);
}
}
this.updateModelForCurrentView();
return;
}
if (eventType === 'startup_progress') {
this.appendStartupProgress(parsedData, sessionId);
const targetSid = evtSessionId || this.currentSessionId();
if (targetSid) {
this.appendStartupProgress(parsedData, targetSid);
const curId = this.currentSessionId();
if (
!this.userPinnedSessionId() &&
(!curId || String(targetSid).trim().toLowerCase() !== String(curId).trim().toLowerCase())
) {
this.agentStatus.set('running');
this.runningSessionId.set(targetSid);
this.selectSession(targetSid, false);
}
}
return;
}
// If the event is for a different session than what user is currently viewing, ignore for logs
const curId = this.currentSessionId();
if (evtSessionId && curId && String(evtSessionId).trim().toLowerCase() !== String(curId).trim().toLowerCase()) {
return;
}
@@ -788,7 +824,9 @@ export class AgentService {
const attemptText = attempt && max ? ` (Attempt ${attempt}/${max})` : '';
const delayText = delay > 0 ? `; retrying in ${delay.toFixed(2).replace(/\.00$/, '')}s` : '';
this.retryMessage.set(`AI service is temporarily busy${attemptText}${delayText}...`);
this.appendLiveLLMRetryTrace(parsedData, sessionId);
if (curId) {
this.appendLiveLLMRetryTrace(parsedData, curId);
}
return;
}
@@ -796,17 +834,19 @@ export class AgentService {
this.isPaused.set(true);
this.isRetrying.set(false);
this.agentStatus.set('paused');
this.runningSessionId.set(sessionId);
this.setSessionStatus(sessionId, 'paused');
const pauseError = parsedData.error || 'AI call failed';
this.pausedError.set(pauseError);
this.appendPausedErrorCard(
pauseError,
sessionId,
parsedData.timestamp,
parsedData.step_id,
parsedData
);
if (curId) {
this.runningSessionId.set(curId);
this.setSessionStatus(curId, 'paused');
const pauseError = parsedData.error || 'AI call failed';
this.pausedError.set(pauseError);
this.appendPausedErrorCard(
pauseError,
curId,
parsedData.timestamp,
parsedData.step_id,
parsedData
);
}
return;
}
@@ -814,9 +854,9 @@ export class AgentService {
this.isPaused.set(false);
this.isRetrying.set(false);
this.pausedError.set(null);
if (this.runningSessionId() === sessionId) {
if (curId && this.runningSessionId() === curId) {
this.agentStatus.set('running');
this.setSessionStatus(sessionId, 'running');
this.setSessionStatus(curId, 'running');
}
this.activePauseCardKey = null;
return;
@@ -833,7 +873,6 @@ export class AgentService {
const streamType = parsedData.stream_type || 'text';
this.sessionLogs.update((logs) => {
// Find if there is an active stream log with same execution_id and stream_type
const existingIndex = logs.findIndex(
(l) => l.type === 'llm_stream' &&
l.data.execution_id === execId &&
@@ -841,7 +880,6 @@ export class AgentService {
);
if (existingIndex > -1) {
// Append text in place
const updatedLogs = [...logs];
const existingLog = updatedLogs[existingIndex];
updatedLogs[existingIndex] = {
@@ -854,7 +892,6 @@ export class AgentService {
};
return updatedLogs;
} else {
// First mark any other active streams of same stream_type as completed
const updatedLogs = logs.map((l) => {
if (l.type === 'llm_stream' &&
(l.data.stream_type || 'text') === streamType &&
@@ -867,7 +904,6 @@ export class AgentService {
return l;
});
// Add new stream log
return [
...updatedLogs,
{
@@ -885,7 +921,6 @@ export class AgentService {
}
});
} else {
// For other event types, mark any active stream as completed
this.sessionLogs.update((logs) => {
const updatedLogs = logs.map((l) => {
if (l.type === 'llm_stream' && !l.data.isCompleted) {
@@ -907,9 +942,6 @@ export class AgentService {
data: parsedData
};
// A retry is published both as trace_recorded and as the
// purpose-built llm_retrying event. Replace the optimistic live
// copy when the persisted trace arrives instead of duplicating it.
if (eventType === 'trace_recorded' && parsedData?.trace_id) {
const existingTraceIndex = updatedLogs.findIndex((log) =>
log.type === 'trace_recorded'
@@ -930,14 +962,13 @@ export class AgentService {
const trName = parsedData.name || '';
if (['save_note', 'read_note', 'update_note', 'append_note', 'list_notes', 'outputter'].includes(trName.toLowerCase())) {
const curSessionId = this.currentSessionId();
if (curSessionId === sessionId) {
if (curSessionId) {
this.fetchNotes(curSessionId);
}
}
}
} catch (e) {
console.error(`Failed to parse ${eventType} event data:`, e);
// Fallback to plain event data if parsing fails
this.sessionLogs.update((logs) => [
...logs,
{
@@ -951,7 +982,7 @@ export class AgentService {
});
this.eventSource.onerror = (err) => {
console.error('EventSource connection error:', err);
console.warn('Persistent live stream issue, browser will auto-reconnect:', err);
};
}
@@ -998,6 +1029,27 @@ export class AgentService {
}
}
});
this.http.get<StartupProgressEvent[]>(`/api/sessions/${sessionId}/startup_progress`).subscribe({
next: (events) => {
if (Array.isArray(events) && events.length > 0) {
this.startupProgressBySession.update((current) => {
const existing = current[sessionId] || [];
const merged = [...existing];
for (const ev of events) {
if (!merged.some((m) => m.stage === ev.stage)) {
merged.push(ev);
}
}
return {
...current,
[sessionId]: merged
};
});
}
},
error: () => {}
});
}
private restoreSessionsCache(): void {
@@ -1268,10 +1320,10 @@ export class AgentService {
this.beginRecordingFinalization(oldRunningSessionId);
}
// Auto-select the session ONLY if nothing is currently selected
// Auto-select the active session if user has not explicitly pinned a historical session
if (isActive && data.session_id) {
const currentId = this.currentSessionId();
if (!currentId) {
if (!currentId || (!this.userPinnedSessionId() && currentId !== data.session_id)) {
this.selectSession(data.session_id, false);
}
}