fix: ensure agents are removed from Mission Control UI on completion

Previously, agents that completed their work would remain visible in the
Mission Control UI until a manual page refresh. This occurred because
the AgentTracker._handle_agent_complete method silently dropped completion
messages when an agent wasn't tracked (e.g., due to missed start messages
from WebSocket connection issues).

Backend changes:
- Modified _handle_agent_complete in server/websocket.py to always emit
  completion messages, even for untracked agents
- Synthetic completions use agentIndex=-1 and agentName='Unknown' as
  sentinel values to indicate untracked agents

Frontend changes:
- Updated useWebSocket.ts to handle synthetic completions by removing
  agents by featureId when agentIndex is -1
- Added 30-minute stale agent cleanup as defense-in-depth for users who
  leave the UI open for extended periods
- Updated TypeScript types to allow 'Unknown' as valid agent name

Component updates:
- AgentAvatar.tsx: Added UNKNOWN_COLORS and UnknownSVG fallback for
  rendering unknown agents with a neutral gray question mark icon
- CelebrationOverlay.tsx, DependencyGraph.tsx: Updated interfaces to
  accept 'Unknown' agent names

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Auto
2026-01-23 13:19:45 +02:00
parent a03d945fcd
commit 1be42cc734
6 changed files with 94 additions and 35 deletions

View File

@@ -238,31 +238,41 @@ class AgentTracker:
}
async def _handle_agent_complete(self, feature_id: int, is_success: bool) -> dict | None:
"""Handle agent completion message from orchestrator."""
"""Handle agent completion - ALWAYS emits a message, even if agent wasn't tracked."""
async with self._lock:
if feature_id not in self.active_agents:
return None
agent = self.active_agents[feature_id]
state = 'success' if is_success else 'error'
agent_type = agent.get('agent_type', 'coding')
result = {
'type': 'agent_update',
'agentIndex': agent['agent_index'],
'agentName': agent['name'],
'agentType': agent_type,
'featureId': feature_id,
'featureName': agent['feature_name'],
'state': state,
'thought': 'Completed successfully!' if is_success else 'Failed to complete',
'timestamp': datetime.now().isoformat(),
}
# Remove from active agents
del self.active_agents[feature_id]
return result
if feature_id in self.active_agents:
# Normal case: agent was tracked
agent = self.active_agents[feature_id]
result = {
'type': 'agent_update',
'agentIndex': agent['agent_index'],
'agentName': agent['name'],
'agentType': agent.get('agent_type', 'coding'),
'featureId': feature_id,
'featureName': agent['feature_name'],
'state': state,
'thought': 'Completed successfully!' if is_success else 'Failed to complete',
'timestamp': datetime.now().isoformat(),
}
del self.active_agents[feature_id]
return result
else:
# Synthetic completion for untracked agent
# This ensures UI always receives completion messages
return {
'type': 'agent_update',
'agentIndex': -1, # Sentinel for untracked
'agentName': 'Unknown',
'agentType': 'coding',
'featureId': feature_id,
'featureName': f'Feature #{feature_id}',
'state': state,
'thought': 'Completed successfully!' if is_success else 'Failed to complete',
'timestamp': datetime.now().isoformat(),
'synthetic': True,
}
class OrchestratorTracker:

View File

@@ -1,12 +1,15 @@
import { type AgentMascot, type AgentState } from '../lib/types'
interface AgentAvatarProps {
name: AgentMascot
name: AgentMascot | 'Unknown'
state: AgentState
size?: 'sm' | 'md' | 'lg'
showName?: boolean
}
// Fallback colors for unknown agents (neutral gray)
const UNKNOWN_COLORS = { primary: '#6B7280', secondary: '#9CA3AF', accent: '#F3F4F6' }
const AVATAR_COLORS: Record<AgentMascot, { primary: string; secondary: string; accent: string }> = {
// Original 5
Spark: { primary: '#3B82F6', secondary: '#60A5FA', accent: '#DBEAFE' }, // Blue robot
@@ -473,6 +476,19 @@ function FluxSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Flux; size: nu
)
}
// Unknown agent fallback - simple question mark icon
function UnknownSVG({ colors, size }: { colors: typeof UNKNOWN_COLORS; size: number }) {
return (
<svg width={size} height={size} viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
{/* Circle background */}
<circle cx="32" cy="32" r="28" fill={colors.primary} />
<circle cx="32" cy="32" r="24" fill={colors.secondary} />
{/* Question mark */}
<text x="32" y="44" textAnchor="middle" fontSize="32" fontWeight="bold" fill="white">?</text>
</svg>
)
}
const MASCOT_SVGS: Record<AgentMascot, typeof SparkSVG> = {
// Original 5
Spark: SparkSVG,
@@ -561,9 +577,11 @@ function getStateDescription(state: AgentState): string {
}
export function AgentAvatar({ name, state, size = 'md', showName = false }: AgentAvatarProps) {
const colors = AVATAR_COLORS[name]
// Handle 'Unknown' agents (synthetic completions from untracked agents)
const isUnknown = name === 'Unknown'
const colors = isUnknown ? UNKNOWN_COLORS : AVATAR_COLORS[name]
const { svg: svgSize, font } = SIZES[size]
const SvgComponent = MASCOT_SVGS[name]
const SvgComponent = isUnknown ? UnknownSVG : MASCOT_SVGS[name]
const stateDesc = getStateDescription(state)
const ariaLabel = `Agent ${name} is ${stateDesc}`

View File

@@ -4,7 +4,7 @@ import { AgentAvatar } from './AgentAvatar'
import type { AgentMascot } from '../lib/types'
interface CelebrationOverlayProps {
agentName: AgentMascot
agentName: AgentMascot | 'Unknown'
featureName: string
onComplete?: () => void
}

View File

@@ -32,7 +32,7 @@ interface DependencyGraphProps {
// Agent info to display on a node
interface NodeAgentInfo {
name: AgentMascot
name: AgentMascot | 'Unknown'
state: AgentState
}

View File

@@ -24,7 +24,7 @@ interface ActivityItem {
// Celebration trigger for overlay
interface CelebrationTrigger {
agentName: AgentMascot
agentName: AgentMascot | 'Unknown'
featureName: string
featureId: number
}
@@ -190,9 +190,18 @@ export function useProjectWebSocket(projectName: string | null) {
if (message.state === 'success' || message.state === 'error') {
// Remove agent from active list on completion (success or failure)
// But keep the logs in agentLogs map for debugging
newAgents = prev.activeAgents.filter(
a => a.agentIndex !== message.agentIndex
)
if (message.agentIndex === -1) {
// Synthetic completion: remove by featureId
// This handles agents that weren't tracked but still completed
newAgents = prev.activeAgents.filter(
a => a.featureId !== message.featureId
)
} else {
// Normal completion: remove by agentIndex
newAgents = prev.activeAgents.filter(
a => a.agentIndex !== message.agentIndex
)
}
} else if (existingAgentIdx >= 0) {
// Update existing agent
newAgents = [...prev.activeAgents]
@@ -411,6 +420,27 @@ export function useProjectWebSocket(projectName: string | null) {
}
}, [projectName, connect, sendPing])
// Defense-in-depth: cleanup stale agents for users who leave UI open for hours
// This catches edge cases where completion messages are missed
useEffect(() => {
const STALE_THRESHOLD_MS = 30 * 60 * 1000 // 30 minutes
const cleanup = setInterval(() => {
setState(prev => {
const now = Date.now()
const fresh = prev.activeAgents.filter(a =>
now - new Date(a.timestamp).getTime() < STALE_THRESHOLD_MS
)
if (fresh.length !== prev.activeAgents.length) {
return { ...prev, activeAgents: fresh }
}
return prev
})
}, 60000) // Check every minute
return () => clearInterval(cleanup)
}, [])
// Clear logs function
const clearLogs = useCallback(() => {
setState(prev => ({ ...prev, logs: [] }))

View File

@@ -195,8 +195,8 @@ export interface AgentLogEntry {
// Agent update from backend
export interface ActiveAgent {
agentIndex: number
agentName: AgentMascot
agentIndex: number // -1 for synthetic completions
agentName: AgentMascot | 'Unknown'
agentType: AgentType // "coding" or "testing"
featureId: number
featureName: string
@@ -265,14 +265,15 @@ export interface WSLogMessage {
export interface WSAgentUpdateMessage {
type: 'agent_update'
agentIndex: number
agentName: AgentMascot
agentIndex: number // -1 for synthetic completions (untracked agents)
agentName: AgentMascot | 'Unknown'
agentType: AgentType // "coding" or "testing"
featureId: number
featureName: string
state: AgentState
thought?: string
timestamp: string
synthetic?: boolean // True for synthetic completions from untracked agents
}
export interface WSAgentStatusMessage {