feat: add global device queue and readiness tracking

This commit is contained in:
somew1nd committed 2026-08-24 14:34:17 -07:00
1 parent a62ac4aef1
commit 42b8988661
57 files changed
+3606 -716

No files matched your search

@@ -0,0 +1,55 @@
import { StartupProgressEvent } from '../../services/agent.service';
import { buildStartupWorkItems } from './agent-stream.component';
describe('startup Work block', () => {
it('shows only the three device preparation operations', () => {
const events: StartupProgressEvent[] = [
{ stage: 'submitting', message: 'Submitting the task', timestamp: 100 },
{ stage: 'queued', message: 'Task received and queued', timestamp: 100.9 },
{ stage: 'launching', message: 'Starting the execution process', timestamp: 101 },
{ stage: 'configuration', message: 'Loading the run configuration', timestamp: 103.1 },
{ stage: 'device_check', message: 'Checking the Android device', timestamp: 103.2 },
{ stage: 'device_ready', message: 'Android device connected', timestamp: 123 },
{ stage: 'model_warmup', message: 'Warming the model connection', timestamp: 123 },
{ stage: 'uiautomator', message: 'Connecting to UI Automator', timestamp: 124 },
{ stage: 'uiautomator_ready', message: 'UI Automator is ready', timestamp: 128 },
{ stage: 'environment', message: 'Preparing the device environment', timestamp: 128 },
{ stage: 'environment_ready', message: 'Device environment is ready', timestamp: 131 }
];
const items = buildStartupWorkItems(events, 131, false, true);
expect(items.map((item) => item.stage)).toEqual([
'device_ready',
'uiautomator_ready',
'environment_ready'
]);
expect(items.map((item) => item.elapsed)).toEqual(['20s', '4.0s', '3.0s']);
expect(items.every((item) => !item.isActive)).toBeTrue();
});
it('keeps the current preparation operation live without exposing first-action text', () => {
const events: StartupProgressEvent[] = [
{ stage: 'device_check', message: 'Checking the Android device', timestamp: 10 }
];
const [item] = buildStartupWorkItems(events, 11.4, false, true);
expect(item.message).toBe('Checking the Android device');
expect(item.elapsed).toBe('1.4s');
expect(item.isActive).toBeTrue();
});
it('uses old first_response events only as a hidden completion boundary', () => {
const events: StartupProgressEvent[] = [
{ stage: 'environment', message: 'Preparing the device environment', timestamp: 20 },
{ stage: 'first_response', message: 'Generating the first action', timestamp: 23 }
];
const [item] = buildStartupWorkItems(events, 24, false, true);
expect(item.message).toBe('Device environment is ready');
expect(item.elapsed).toBe('3.0s');
expect(item.isActive).toBeFalse();
});
});
@@ -290,40 +290,43 @@
<!-- Event Logs Section -->
<main class="stream-logs-content" (scroll)="onStreamScroll($event)">
<!-- Keep recovery available even when a persisted failure trace is absent. -->
<div class="paused-task-banner" *ngIf="isViewingPausedTask()" role="status">
<div class="paused-task-message">
<span class="material-symbols-outlined paused-task-icon">pause_circle</span>
<div>
<strong>Task paused</strong>
<span>The AI request failed. Resume the task to try again.</span>
</div>
</div>
<button
type="button"
class="btn-resume-task"
(click)="resumePausedTask($event)"
title="Resume the paused task"
aria-label="Resume the paused task"
>
<span class="material-symbols-outlined">play_arrow</span>
Resume
</button>
</div>
<div *ngIf="consolidatedBlocks().length === 0 && !showPlanningLoader()" class="empty-state">
<div *ngIf="consolidatedBlocks().length === 0 && !showPlanningLoader() && startupWorkItems().length === 0" class="empty-state">
<div class="empty-icon"><span class="material-symbols-outlined">sensors</span></div>
<h3>No Stream Activity Selected</h3>
<p>Please select a session from the Task List on the right to view its real-time log payloads.</p>
</div>
<!-- The phase-based session logs list -->
<div class="logs-list-container" *ngIf="phases().length > 0 || showPlanningLoader()">
<div class="logs-list-container" *ngIf="phases().length > 0 || showPlanningLoader() || startupWorkItems().length > 0">
<div class="phase-container startup-phase" *ngIf="startupWorkItems().length > 0" aria-live="polite">
<div class="phase-header first-phase">
<span class="phase-worked-time">Worked for {{ startupPreparationDuration() }}s</span>
</div>
<div class="phase-body" aria-label="Task startup progress">
<div class="block-item type-stream">
<div class="step-body">
<div class="step-actions-container">
<div class="llm-retry-notes">
<div class="llm-retry-note" *ngFor="let item of startupWorkItems()">
<span class="stream-status-dot" [class.streaming]="item.isActive"></span>
<div class="llm-retry-note-text">
<span class="llm-retry-status-line">
{{ item.message }} · {{ item.isActive ? 'Waiting ' : 'Waited ' }}{{ item.elapsed }}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="phase-container" *ngFor="let phase of phases(); let first = first; trackBy: trackPhase">
<div
class="phase-header"
*ngIf="hasVisibleBlocks(phase)"
[class.first-phase]="first"
[class.first-phase]="first && startupWorkItems().length === 0"
>
<span class="phase-worked-time">Worked for {{ phase.durationSeconds }}s</span>
<span class="phase-token-usage" *ngIf="phase.tokens" [title]="getTokenTooltip(phase)">
@@ -558,11 +561,11 @@
</div>
<!-- Failed LLM Calls (Permanently maintain Action Card format in red) -->
<div class="step-android-action-card tool-action-card is-failed"
[class.has-details]="hasLLMFailureRetryEntries(item.data)"
[class.is-expanded]="isActionExpanded('llm-failure-' + block.id + '-' + itemIdx)"
(click)="hasLLMFailureRetryEntries(item.data) && toggleActionExpanded('llm-failure-' + block.id + '-' + itemIdx, $event)"
*ngIf="isDisplayableLLMFailure(item.data)">
<div class="llm-failure-stack" *ngIf="isDisplayableLLMFailure(item.data)">
<div class="step-android-action-card tool-action-card is-failed"
[class.has-details]="hasLLMFailureRetryEntries(item.data)"
[class.is-expanded]="isActionExpanded('llm-failure-' + block.id + '-' + itemIdx)"
(click)="hasLLMFailureRetryEntries(item.data) && toggleActionExpanded('llm-failure-' + block.id + '-' + itemIdx, $event)">
<div class="action-card-header llm-failure-header">
<span class="material-symbols-outlined action-icon is-failed">error</span>
<span class="action-title">LLM Request Failed</span>
@@ -576,24 +579,17 @@
Failed
</span>
</ng-template>
<button
type="button"
class="btn-resume-llm"
*ngIf="isTerminalLLMFailure(item.data)"
(click)="resumePausedTask($event)"
title="Resume the paused task"
aria-label="Resume the paused task"
>
<span class="material-symbols-outlined">play_arrow</span>
Resume
</button>
<span class="action-expand-icon material-symbols-outlined" *ngIf="hasLLMFailureRetryEntries(item.data)">
{{ isActionExpanded('llm-failure-' + block.id + '-' + itemIdx) ? 'expand_less' : 'expand_more' }}
</span>
</div>
<div class="action-card-body">
<div class="action-detail-row">
<span class="detail-label">Reason:</span>
<span class="detail-label">What happened:</span>
<span class="detail-value">The AI request could not be completed after automatic retries.</span>
</div>
<div class="action-detail-row">
<span class="detail-label">Failure reason:</span>
<span class="detail-value">{{ getLLMErrorText(item.data) }}</span>
</div>
<div class="action-detail-row" *ngIf="getLLMFailureWaited(item.data) as waited">
@@ -611,6 +607,22 @@
</div>
</div>
</div>
</div>
<div class="llm-resume-row" *ngIf="isTerminalLLMFailure(item.data)">
<button
type="button"
class="btn-continue-task"
(click)="resumePausedTask($event)"
title="Continue the paused task"
aria-label="Continue the paused task"
>
<span class="material-symbols-outlined continue-icon">play_arrow</span>
<span>Continue task</span>
<span class="material-symbols-outlined continue-arrow">arrow_forward</span>
</button>
<span class="llm-resume-hint">Task paused · no steps are running</span>
</div>
</div>
<!-- Generic Tool Calls (Text format for note tools, video analyzer, and other sub-agents) -->
@@ -842,7 +854,7 @@
</div>
<!-- Planning Loader -->
<div class="planning-loader-container" *ngIf="showPlanningLoader()">
<div class="planning-loader-container" *ngIf="showPlanningLoader() && (startupWorkItems().length === 0 || startupPreparationIsComplete() || consolidatedBlocks().length > 0)">
<div class="planning-loader">
<span class="material-symbols-outlined spin-animation">progress_activity</span>
<span class="loader-text">{{ currentPlanningText() }}</span>
@@ -1060,99 +1060,6 @@ $uniform-gap: 8px;
}
}
.paused-task-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 14px;
margin-bottom: 12px;
background: #fff7ed;
border: 1px solid #fdba74;
border-radius: 10px;
color: #9a3412;
box-shadow: 0 1px 3px rgba(154, 52, 18, 0.08);
.paused-task-message {
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
.paused-task-icon {
flex: 0 0 auto;
font-size: 21px;
color: #ea580c;
}
div {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
strong {
font-size: 12px;
line-height: 1.2;
}
span:not(.paused-task-icon) {
font-size: 11px;
line-height: 1.35;
color: #c2410c;
}
}
.btn-resume-task {
flex: 0 0 auto;
min-height: 32px;
padding: 6px 12px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
border: 1px solid #2563eb;
border-radius: 8px;
background: #2563eb;
color: #ffffff;
font-family: inherit;
font-size: 12px;
font-weight: 700;
line-height: 1;
cursor: pointer;
box-shadow: 0 2px 5px rgba(37, 99, 235, 0.2);
transition: background 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
.material-symbols-outlined {
font-size: 17px;
}
&:hover {
background: #1d4ed8;
box-shadow: 0 3px 8px rgba(37, 99, 235, 0.28);
}
&:active {
transform: scale(0.97);
}
&:focus-visible {
outline: 2px solid rgba(37, 99, 235, 0.35);
outline-offset: 2px;
}
}
@media (max-width: 560px) {
align-items: stretch;
flex-direction: column;
.btn-resume-task {
width: 100%;
}
}
}
.logs-list-container {
display: flex;
flex-direction: column;
@@ -1203,8 +1110,11 @@ $uniform-gap: 8px;
.phase-header {
font-size: 12px;
font-weight: normal;
line-height: 1.5;
min-height: 20px;
box-sizing: border-box;
color: #71717a;
margin-top: 14px;
margin-top: $uniform-gap * 2;
margin-bottom: 8px;
user-select: none;
display: flex;
@@ -2230,6 +2140,111 @@ $uniform-gap: 8px;
}
}
.llm-failure-stack {
width: 380px;
max-width: 100%;
margin-left: -19px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 10px;
.step-android-action-card {
width: 100%;
margin-left: 0;
box-sizing: border-box;
}
.llm-resume-row {
min-height: 36px;
display: flex;
align-items: center;
gap: 10px;
}
.btn-continue-task {
min-height: 36px;
padding: 7px 12px 7px 10px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
box-sizing: border-box;
border: 1px solid #1d4ed8;
border-radius: 9px;
background: #2563eb;
color: #ffffff;
font-family: inherit;
font-size: 12px;
font-weight: 700;
line-height: 1;
cursor: pointer;
box-shadow: 0 2px 7px rgba(37, 99, 235, 0.22);
transition: background 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
.continue-icon {
width: 22px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
margin: -2px 0 -2px -2px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.16);
font-size: 16px;
}
.continue-arrow {
font-size: 15px;
opacity: 0.72;
transition: transform 0.18s ease, opacity 0.18s ease;
}
&:hover {
background: #1d4ed8;
border-color: #1e40af;
box-shadow: 0 4px 11px rgba(37, 99, 235, 0.28);
transform: translateY(-1px);
.continue-arrow {
opacity: 1;
transform: translateX(2px);
}
}
&:active {
box-shadow: 0 1px 4px rgba(37, 99, 235, 0.2);
transform: translateY(0) scale(0.98);
}
&:focus-visible {
outline: 3px solid rgba(59, 130, 246, 0.25);
outline-offset: 2px;
}
}
.llm-resume-hint {
color: #94a3b8;
font-size: 11px;
line-height: 1.35;
white-space: nowrap;
}
@media (max-width: 560px) {
.llm-resume-row {
width: 100%;
}
.btn-continue-task {
flex: 1;
}
.llm-resume-hint {
display: none;
}
}
}
.step-android-action-card {
background: #f8fafc;
border: 1px solid #e2e8f0;
@@ -2488,51 +2503,6 @@ $uniform-gap: 8px;
}
&.llm-failure-header {
flex-wrap: wrap;
.btn-resume-llm {
flex: 0 0 auto;
min-height: 26px;
padding: 3px 9px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
box-sizing: border-box;
border: 1px solid #bfdbfe;
border-radius: 7px;
background: #ffffff;
color: #2563eb;
font-family: inherit;
font-size: 11px;
font-weight: 700;
line-height: 1;
cursor: pointer;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
transition: background 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
.material-symbols-outlined {
font-size: 15px;
}
&:hover {
background: #eff6ff;
border-color: #93c5fd;
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.12);
}
&:active {
transform: scale(0.97);
}
&:focus-visible {
outline: 2px solid rgba(37, 99, 235, 0.35);
outline-offset: 2px;
}
}
}
.action-expand-icon {
font-size: 18px;
color: #94a3b8;
@@ -19,7 +19,7 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { HttpClient } from '@angular/common/http';
import { AgentService } from '../../services/agent.service';
import { AgentService, StartupProgressEvent } from '../../services/agent.service';
import { Session } from '../../core/models/session.model';
import { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote } from '../../core/models/markdown.model';
import { StepBlock, PhaseBlock, StepEvent, ActionParam, CheckerResult } from '../../core/models/stream.model';
@@ -43,6 +43,83 @@ export const PLANNING_LOADER_PHRASES: string[] = [
'Strategizing tactical moves...'
];
export interface StartupWorkItem extends StartupProgressEvent {
isActive: boolean;
elapsed: string;
}
interface StartupWorkStage {
started: string;
completed: string;
completedMessage: string;
}
const STARTUP_WORK_STAGES: StartupWorkStage[] = [
{
started: 'device_check',
completed: 'device_ready',
completedMessage: 'Android device connected'
},
{
started: 'uiautomator',
completed: 'uiautomator_ready',
completedMessage: 'UI Automator is ready'
},
{
started: 'environment',
completed: 'environment_ready',
completedMessage: 'Device environment is ready'
}
];
function formatStartupElapsed(seconds: number): string {
const safeSeconds = Math.max(0, seconds);
return safeSeconds < 10
? `${safeSeconds.toFixed(1)}s`
: `${Math.round(safeSeconds)}s`;
}
/**
* Collapse noisy process-level startup events into the three device preparation
* operations that are useful to someone watching a run.
*/
export function buildStartupWorkItems(
events: StartupProgressEvent[],
nowSeconds: number,
executionHasOutput: boolean,
agentIsActive: boolean
): StartupWorkItem[] {
const byStage = new Map(events.map((event) => [event.stage, event]));
const firstResponse = byStage.get('first_response');
return STARTUP_WORK_STAGES.flatMap((stage, stageIndex) => {
const started = byStage.get(stage.started);
const explicitlyCompleted = byStage.get(stage.completed);
if (!started && !explicitlyCompleted) return [];
const nextStageStarted = STARTUP_WORK_STAGES
.slice(stageIndex + 1)
.map((nextStage) => byStage.get(nextStage.started) || byStage.get(nextStage.completed))
.find((event): event is StartupProgressEvent => Boolean(event));
const inferredCompletion = stage.started === 'environment'
? firstResponse
: nextStageStarted;
const completed = explicitlyCompleted || inferredCompletion;
const isActive = !completed && !executionHasOutput && agentIsActive;
const startTimestamp = started?.timestamp || explicitlyCompleted?.timestamp || nowSeconds;
const endTimestamp = completed?.timestamp
|| (isActive ? nowSeconds : events[events.length - 1]?.timestamp || startTimestamp);
return [{
...(explicitlyCompleted || started!),
message: explicitlyCompleted?.message
|| (completed ? stage.completedMessage : started!.message),
isActive,
elapsed: formatStartupElapsed(endTimestamp - startTimestamp)
}];
});
}
import {
parseNote,
parseNoteLines,
@@ -232,7 +309,10 @@ export class AgentStreamComponent implements AfterViewInit {
});
public isViewingPausedTask = computed(() => {
if (!this.agentService.isPaused()) return false;
// A live task_paused event and the polled runner status must agree before
// recovery controls are shown. This prevents a stale failure trace from
// offering Resume while the active task is still reported as running.
if (!this.agentService.isPaused() || this.agentService.agentStatus() !== 'paused') return false;
const currentSessionId = this.agentService.currentSessionId();
const pausedSessionId = this.agentService.runningSessionId();
@@ -253,8 +333,44 @@ export class AgentStreamComponent implements AfterViewInit {
return groupBlocksToPhases(this.consolidatedBlocks(), currentSession?.start_time || 0);
});
public startupWorkItems = computed(() => {
this.retryClock();
const events = this.agentService.currentStartupProgress();
const agentIsActive = ['running', 'paused'].includes(this.agentService.agentStatus());
return buildStartupWorkItems(
events,
Date.now() / 1000,
this.consolidatedBlocks().length > 0,
agentIsActive
);
});
public startupPreparationIsRunning = computed(() => {
return this.startupWorkItems().some((item) => item.isActive);
});
public startupPreparationIsComplete = computed(() => {
const items = this.startupWorkItems();
return items.length > 0 && !items.some((item) => item.isActive);
});
public startupPreparationDuration = computed(() => {
const items = this.startupWorkItems();
if (items.length === 0) return 0;
const events = this.agentService.currentStartupProgress();
const startedAt = events[0]?.timestamp || items[0].timestamp;
const completedAt = events.find((event) => event.stage === 'environment_ready')?.timestamp
|| events.find((event) => event.stage === 'first_response')?.timestamp;
const endedAt = this.startupPreparationIsRunning()
? Date.now() / 1000
: completedAt || Math.max(...items.map((item) => item.timestamp));
return Math.max(1, Math.round(endedAt - startedAt));
});
private retryablePauseTrace = computed<any | null>(() => {
if (!this.agentService.isPaused()) return null;
if (!this.isViewingPausedTask()) return null;
const blocks = this.consolidatedBlocks();
for (let blockIndex = blocks.length - 1; blockIndex >= 0; blockIndex--) {
@@ -263,11 +379,9 @@ export class AgentStreamComponent implements AfterViewInit {
for (let toolIndex = tools.length - 1; toolIndex >= 0; toolIndex--) {
const tool = tools[toolIndex];
if (
tool?.type === 'llm_call'
&& tool?.status === 'failed'
&& tool?.payload?.pause === true
) {
// Older persisted traces used the llm_pause name without adding the
// newer payload.pause marker. Treat both contracts consistently.
if (this.isDisplayableLLMFailure(tool)) {
return tool;
}
}
@@ -873,10 +987,12 @@ export class AgentStreamComponent implements AfterViewInit {
}
public getLLMErrorText(tool: any): string {
if (!tool) return 'Unknown error';
const noDetails = 'The AI provider did not return error details after the request failed.';
if (!tool) return noDetails;
const rawError = tool.payload?.error || tool.error;
if (!rawError) return 'Unknown error';
return this.cleanErrorMessage(rawError);
if (!rawError) return noDetails;
const cleaned = this.cleanErrorMessage(rawError);
return cleaned === 'Unknown error' ? noDetails : cleaned;
}
public isLLMRetry(tool: any): boolean {
@@ -17,7 +17,6 @@
import { Component, inject, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { AgentService } from '../../services/agent.service';
import { Session } from '../../core/models/session.model';
import { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote } from '../../core/models/markdown.model';
@@ -34,7 +33,6 @@ export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote };
})
export class ChatInterfaceComponent {
public agentService = inject(AgentService);
private http = inject(HttpClient);
public taskInput: string = '';
public isSubmitting: boolean = false;
@@ -79,23 +77,10 @@ export class ChatInterfaceComponent {
this.isSubmitting = true;
this.errorMessage = null;
this.agentService.clearUserPinnedSession();
this.http.post<any>('/api/run', { goal }).subscribe({
this.agentService.runTask(goal).subscribe({
next: (res) => {
this.taskInput = '';
this.isSubmitting = false;
if (res && res.tasks && res.tasks.length > 0) {
const firstTask = res.tasks[0];
if (firstTask && firstTask.session_id) {
// Only auto-select immediately if no task is currently executing/running
const isCurrentlyRunning =
this.agentService.agentStatus() === 'running' ||
this.agentService.sessions().some((s) => s.status === 'running');
if (!isCurrentlyRunning) {
this.agentService.selectSession(firstTask.session_id, false);
}
}
}
// Fetch status to refresh sessions list and select new session
this.agentService.fetchStatus();
},