perf: replace agents polling with WebSocket, fix workbox prod mode (#254)

This commit is contained in:
Andrew Park
2026-04-17 11:00:50 -07:00
committed by GitHub
parent 7108be9aec
commit 48c23d87df
7 changed files with 122 additions and 11 deletions
+3
View File
@@ -103,3 +103,6 @@ MagicMock/
# Second Repos
Inline/
# Local git worktrees
.worktrees/
+90
View File
@@ -0,0 +1,90 @@
import { useEffect, useRef } from 'react';
import { getBase } from './api';
export interface AgentEvent {
type: string;
timestamp: number;
data: Record<string, unknown>;
}
function buildWsUrl(agentId?: string): string {
const base = getBase();
let origin: string;
if (base) {
origin = base.replace(/^http/, 'ws');
} else {
const loc = window.location;
origin = `${loc.protocol === 'https:' ? 'wss:' : 'ws:'}//${loc.host}`;
}
const path = '/v1/agents/events';
return agentId
? `${origin}${path}?agent_id=${encodeURIComponent(agentId)}`
: `${origin}${path}`;
}
/**
* Subscribe to agent events over WebSocket.
* Auto-reconnects with backoff when the socket drops.
*/
export function useAgentEvents(
agentId: string | undefined,
onEvent: (event: AgentEvent) => void,
eventTypes?: readonly string[],
): void {
const onEventRef = useRef(onEvent);
onEventRef.current = onEvent;
const typesRef = useRef(eventTypes);
typesRef.current = eventTypes;
useEffect(() => {
if (!agentId) return;
let ws: WebSocket | null = null;
let closed = false;
let retry = 0;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
const connect = () => {
if (closed) return;
try {
ws = new WebSocket(buildWsUrl(agentId));
} catch {
schedule();
return;
}
ws.onopen = () => {
retry = 0;
};
ws.onmessage = (msg) => {
try {
const payload = JSON.parse(msg.data) as AgentEvent;
const allowed = typesRef.current;
if (allowed && !allowed.includes(payload.type)) return;
onEventRef.current(payload);
} catch {
// ignore malformed payload
}
};
ws.onclose = () => {
if (!closed) schedule();
};
ws.onerror = () => {
ws?.close();
};
};
const schedule = () => {
if (closed) return;
const delay = Math.min(30000, 1000 * 2 ** Math.min(retry, 5));
retry += 1;
reconnectTimer = setTimeout(connect, delay);
};
connect();
return () => {
closed = true;
if (reconnectTimer) clearTimeout(reconnectTimer);
ws?.close();
};
}, [agentId]);
}
+24 -2
View File
@@ -32,6 +32,7 @@ import {
sendblueHealth,
} from '../lib/api';
import type { AgentTask, ChannelBinding, AgentTemplate, AgentMessage, ManagedAgent, LearningLogEntry, AgentTrace, ToolInfo } from '../lib/api';
import { useAgentEvents } from '../lib/useAgentEvents';
import {
Plus,
Bot,
@@ -1332,10 +1333,21 @@ function InteractTab({ agentId, agentStatus }: { agentId: string; agentStatus: s
useEffect(() => {
loadData();
const interval = setInterval(loadData, 2000);
// Fallback slow poll — WS is primary, this catches missed events / dropped sockets
const interval = setInterval(loadData, 30000);
return () => clearInterval(interval);
}, [loadData]);
// Event-driven refresh — fires when the server reports agent activity
useAgentEvents(agentId, loadData, [
'agent_tick_start',
'agent_tick_end',
'agent_tick_error',
'agent_message_received',
'tool_call_end',
'inference_end',
]);
useEffect(() => { setLiveStatus(agentStatus); }, [agentStatus]);
// Clean up elapsed-time timer on unmount
@@ -2854,10 +2866,20 @@ function LogsTab({ agentId }: { agentId: string }) {
useEffect(() => {
loadData();
const interval = setInterval(loadData, 5000);
// Fallback slow poll — WS is primary, this catches missed events
const interval = setInterval(loadData, 30000);
return () => clearInterval(interval);
}, [loadData]);
// Event-driven refresh — trace/learning entries are created by tick + tool events
useAgentEvents(agentId, loadData, [
'agent_tick_end',
'agent_tick_error',
'tool_call_end',
'inference_end',
'agent_learning_completed',
]);
// Merge traces and learning entries into a unified timeline
type TimelineEntry =
| { kind: 'trace'; data: AgentTrace; ts: number }
-1
View File
@@ -30,7 +30,6 @@ export default defineConfig({
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
navigateFallbackDenylist: [/^\/v1\//, /^\/health/, /^\/dashboard/],
mode: 'development',
},
}),
],
@@ -141,7 +141,7 @@ impl ICLUpdaterPolicy {
}
}
skills.sort_by(|a, b| b.occurrences.cmp(&a.occurrences));
skills.sort_by_key(|s| std::cmp::Reverse(s.occurrences));
self.discovered_skills = skills;
&self.discovered_skills
}
@@ -219,7 +219,7 @@ impl TrainingDataMiner {
}
}
let mut ranked: Vec<_> = tool_freq.into_iter().collect();
ranked.sort_by(|a, b| b.1.cmp(&a.1));
ranked.sort_by_key(|r| std::cmp::Reverse(r.1));
let best_tools: Vec<String> = ranked.into_iter().map(|(name, _)| name).collect();
let all_scores: Vec<f64> = agent_scores.values().flat_map(|v| v.iter().copied()).collect();
+3 -6
View File
@@ -136,13 +136,10 @@ impl WorkflowGraph {
continue;
}
if let Some(&vi) = self.node_index.get(&edge.target) {
match color[vi] {
let c = color[vi];
match c {
1 => return true,
0 => {
if self.dfs_has_cycle(vi, color) {
return true;
}
}
0 if self.dfs_has_cycle(vi, color) => return true,
_ => {}
}
}