Fixed the socket optimistic updates. And the MCP for update task.

This commit is contained in:
sean-eskerium
2025-08-20 23:31:47 -04:00
parent c16498ceab
commit 703f2bca7c
5 changed files with 200 additions and 78 deletions

View File

@@ -511,10 +511,10 @@ export const TasksTab = ({
debouncedPersistBatchReorder(tasksToUpdate); debouncedPersistBatchReorder(tasksToUpdate);
}, [tasks, updateTasks, debouncedPersistBatchReorder]); }, [tasks, updateTasks, debouncedPersistBatchReorder]);
// Task move function (for board view) // Task move function (for board view) with optimistic UI update
const moveTask = async (taskId: string, newStatus: Task['status']) => { const moveTask = async (taskId: string, newStatus: Task['status']) => {
console.log(`[TasksTab] Attempting to move task ${taskId} to new status: ${newStatus}`); console.log(`[TasksTab] Attempting to move task ${taskId} to new status: ${newStatus}`);
try {
const movingTask = tasks.find(task => task.id === taskId); const movingTask = tasks.find(task => task.id === taskId);
if (!movingTask) { if (!movingTask) {
console.warn(`[TasksTab] Task ${taskId} not found for move operation.`); console.warn(`[TasksTab] Task ${taskId} not found for move operation.`);
@@ -523,21 +523,38 @@ export const TasksTab = ({
const oldStatus = movingTask.status; const oldStatus = movingTask.status;
const newOrder = getNextOrderForStatus(newStatus); const newOrder = getNextOrderForStatus(newStatus);
const updatedTask = { ...movingTask, status: newStatus, task_order: newOrder };
console.log(`[TasksTab] Moving task ${movingTask.title} from ${oldStatus} to ${newStatus} with order ${newOrder}`); console.log(`[TasksTab] Moving task ${movingTask.title} from ${oldStatus} to ${newStatus} with order ${newOrder}`);
// Update the task with new status and order // OPTIMISTIC UPDATE: Update UI immediately
setTasks(prev => {
const updated = prev.map(task => task.id === taskId ? updatedTask : task);
setTimeout(() => onTasksChange(updated), 0);
return updated;
});
console.log(`[TasksTab] Optimistically updated UI for task ${taskId}`);
try {
// Then update the backend
await projectService.updateTask(taskId, { await projectService.updateTask(taskId, {
status: mapUIStatusToDBStatus(newStatus), status: mapUIStatusToDBStatus(newStatus),
task_order: newOrder task_order: newOrder
}); });
console.log(`[TasksTab] Successfully updated task ${taskId} status in backend.`); console.log(`[TasksTab] Successfully updated task ${taskId} status in backend.`);
// Don't update local state immediately - let socket handle it // Socket will confirm the update, but UI is already updated
console.log(`[TasksTab] Waiting for socket update for task ${taskId}.`);
} catch (error) { } catch (error) {
console.error(`[TasksTab] Failed to move task ${taskId}:`, error); console.error(`[TasksTab] Failed to move task ${taskId}, rolling back:`, error);
// ROLLBACK on error - restore original task
setTasks(prev => {
const updated = prev.map(task => task.id === taskId ? movingTask : task);
setTimeout(() => onTasksChange(updated), 0);
return updated;
});
alert(`Failed to move task: ${error instanceof Error ? error.message : 'Unknown error'}`); alert(`Failed to move task: ${error instanceof Error ? error.message : 'Unknown error'}`);
} }
}; };
@@ -557,15 +574,50 @@ export const TasksTab = ({
if (!taskToDelete) return; if (!taskToDelete) return;
try { try {
// Delete (actually archives) the task - backend will emit socket event // Add to recently deleted cache to prevent race conditions
await projectService.deleteTask(taskToDelete.id); setRecentlyDeletedIds(prev => new Set(prev).add(taskToDelete.id));
console.log(`[TasksTab] Task ${taskToDelete.id} archival sent to backend`);
// Don't update local state - let socket handle it // OPTIMISTIC UPDATE: Remove task from UI immediately
setTasks(prev => {
const updated = prev.filter(t => t.id !== taskToDelete.id);
setTimeout(() => onTasksChange(updated), 0);
return updated;
});
console.log(`[TasksTab] Optimistically removed task ${taskToDelete.id} from UI`);
// Then delete from backend
await projectService.deleteTask(taskToDelete.id);
console.log(`[TasksTab] Task ${taskToDelete.id} deletion confirmed by backend`);
// Clear from recently deleted cache after a delay (to catch any lingering socket events)
setTimeout(() => {
setRecentlyDeletedIds(prev => {
const newSet = new Set(prev);
newSet.delete(taskToDelete.id);
return newSet;
});
}, 3000); // 3 second window to ignore stale socket events
} catch (error) { } catch (error) {
console.error('Failed to archive task:', error); console.error('Failed to delete task:', error);
// Note: The toast notification for deletion is now handled by TaskBoardView and TaskTableView
// Remove from recently deleted cache on error
setRecentlyDeletedIds(prev => {
const newSet = new Set(prev);
newSet.delete(taskToDelete.id);
return newSet;
});
// ROLLBACK on error - restore the task
setTasks(prev => {
const updated = [...prev, taskToDelete].sort((a, b) => a.task_order - b.task_order);
setTimeout(() => onTasksChange(updated), 0);
return updated;
});
console.log(`[TasksTab] Rolled back task deletion for ${taskToDelete.id}`);
// Re-throw to let the calling component handle the error display
throw error;
} finally { } finally {
setTaskToDelete(null); setTaskToDelete(null);
setShowDeleteConfirm(false); setShowDeleteConfirm(false);

View File

@@ -6,7 +6,7 @@
* approach that avoids conflicts and connection issues. * approach that avoids conflicts and connection issues.
*/ */
import { useEffect, useRef, useCallback } from 'react'; import { useEffect, useRef, useCallback, useState } from 'react';
import { taskSocketService, TaskSocketEvents } from '../services/taskSocketService'; import { taskSocketService, TaskSocketEvents } from '../services/taskSocketService';
import { WebSocketState } from '../services/socketIOService'; import { WebSocketState } from '../services/socketIOService';
@@ -37,6 +37,10 @@ export function useTaskSocket(options: UseTaskSocketOptions) {
const currentProjectIdRef = useRef<string | null>(null); const currentProjectIdRef = useRef<string | null>(null);
const isInitializedRef = useRef<boolean>(false); const isInitializedRef = useRef<boolean>(false);
// Add reactive state for connection status
const [isConnected, setIsConnected] = useState<boolean>(false);
const [connectionState, setConnectionState] = useState<WebSocketState>(WebSocketState.DISCONNECTED);
// Memoized handlers to prevent unnecessary re-registrations // Memoized handlers to prevent unnecessary re-registrations
const memoizedHandlers = useCallback((): Partial<TaskSocketEvents> => { const memoizedHandlers = useCallback((): Partial<TaskSocketEvents> => {
return { return {
@@ -58,6 +62,44 @@ export function useTaskSocket(options: UseTaskSocketOptions) {
onConnectionStateChange onConnectionStateChange
]); ]);
// Subscribe to connection state changes
useEffect(() => {
const checkConnection = () => {
const connected = taskSocketService.isConnected();
const state = taskSocketService.getConnectionState();
setIsConnected(connected);
setConnectionState(state);
};
// Check initial state
checkConnection();
// Poll for connection state changes (since the service doesn't expose event emitters)
const interval = setInterval(checkConnection, 500);
// Also trigger when connection state handler is called
const wrappedOnConnectionStateChange = onConnectionStateChange ? (state: WebSocketState) => {
setConnectionState(state);
setIsConnected(state === WebSocketState.CONNECTED);
onConnectionStateChange(state);
} : (state: WebSocketState) => {
setConnectionState(state);
setIsConnected(state === WebSocketState.CONNECTED);
};
// Update the handler
if (componentIdRef.current && taskSocketService) {
taskSocketService.registerHandlers(componentIdRef.current, {
...memoizedHandlers(),
onConnectionStateChange: wrappedOnConnectionStateChange
});
}
return () => {
clearInterval(interval);
};
}, [onConnectionStateChange, memoizedHandlers]);
// Initialize connection once and register handlers // Initialize connection once and register handlers
useEffect(() => { useEffect(() => {
if (!projectId || isInitializedRef.current) return; if (!projectId || isInitializedRef.current) return;
@@ -65,6 +107,7 @@ export function useTaskSocket(options: UseTaskSocketOptions) {
const initializeConnection = async () => { const initializeConnection = async () => {
try { try {
console.log(`[USE_TASK_SOCKET] Initializing connection for project: ${projectId}`); console.log(`[USE_TASK_SOCKET] Initializing connection for project: ${projectId}`);
setConnectionState(WebSocketState.CONNECTING);
// Register handlers first // Register handlers first
taskSocketService.registerHandlers(componentIdRef.current, memoizedHandlers()); taskSocketService.registerHandlers(componentIdRef.current, memoizedHandlers());
@@ -76,8 +119,14 @@ export function useTaskSocket(options: UseTaskSocketOptions) {
isInitializedRef.current = true; isInitializedRef.current = true;
console.log(`[USE_TASK_SOCKET] Successfully initialized for project: ${projectId}`); console.log(`[USE_TASK_SOCKET] Successfully initialized for project: ${projectId}`);
// Update connection state after successful connection
setIsConnected(taskSocketService.isConnected());
setConnectionState(taskSocketService.getConnectionState());
} catch (error) { } catch (error) {
console.error(`[USE_TASK_SOCKET] Failed to initialize for project ${projectId}:`, error); console.error(`[USE_TASK_SOCKET] Failed to initialize for project ${projectId}:`, error);
setConnectionState(WebSocketState.DISCONNECTED);
setIsConnected(false);
} }
}; };
@@ -103,6 +152,8 @@ export function useTaskSocket(options: UseTaskSocketOptions) {
const switchProject = async () => { const switchProject = async () => {
try { try {
setConnectionState(WebSocketState.CONNECTING);
// Update handlers for new project // Update handlers for new project
taskSocketService.registerHandlers(componentIdRef.current, memoizedHandlers()); taskSocketService.registerHandlers(componentIdRef.current, memoizedHandlers());
@@ -112,8 +163,14 @@ export function useTaskSocket(options: UseTaskSocketOptions) {
currentProjectIdRef.current = projectId; currentProjectIdRef.current = projectId;
console.log(`[USE_TASK_SOCKET] Successfully switched to project: ${projectId}`); console.log(`[USE_TASK_SOCKET] Successfully switched to project: ${projectId}`);
// Update connection state
setIsConnected(taskSocketService.isConnected());
setConnectionState(taskSocketService.getConnectionState());
} catch (error) { } catch (error) {
console.error(`[USE_TASK_SOCKET] Failed to switch to project ${projectId}:`, error); console.error(`[USE_TASK_SOCKET] Failed to switch to project ${projectId}:`, error);
setConnectionState(WebSocketState.DISCONNECTED);
setIsConnected(false);
} }
}; };
@@ -132,10 +189,10 @@ export function useTaskSocket(options: UseTaskSocketOptions) {
}; };
}, []); }, []);
// Return utility functions // Return reactive state and utility functions
return { return {
isConnected: taskSocketService.isConnected(), isConnected, // Now reactive!
connectionState: taskSocketService.getConnectionState(), connectionState, // Now reactive!
reconnect: taskSocketService.reconnect.bind(taskSocketService), reconnect: taskSocketService.reconnect.bind(taskSocketService),
getCurrentProjectId: taskSocketService.getCurrentProjectId.bind(taskSocketService) getCurrentProjectId: taskSocketService.getCurrentProjectId.bind(taskSocketService)
}; };

View File

@@ -678,28 +678,15 @@ export function createWebSocketService(config?: WebSocketConfig): WebSocketServi
return new WebSocketService(config); return new WebSocketService(config);
} }
// Create SEPARATE WebSocket instances for different features // Create a SINGLE shared WebSocket instance to prevent multiple connections
// This prevents a failure in one feature (like a long crawl) from breaking the entire site // This fixes the socket disconnection issue when switching tabs
export const knowledgeSocketIO = new WebSocketService({ const sharedSocketInstance = new WebSocketService();
maxReconnectAttempts: 10, // More attempts for crawls that can take a long time
reconnectInterval: 2000,
heartbeatInterval: 30000,
enableAutoReconnect: true
});
export const taskUpdateSocketIO = new WebSocketService({ // Export the SAME instance with different names for backward compatibility
maxReconnectAttempts: 5, // This ensures only ONE Socket.IO connection is created and shared across all features
reconnectInterval: 1000, export const knowledgeSocketIO = sharedSocketInstance;
heartbeatInterval: 30000, export const taskUpdateSocketIO = sharedSocketInstance;
enableAutoReconnect: true export const projectListSocketIO = sharedSocketInstance;
});
export const projectListSocketIO = new WebSocketService({ // Export as default for new code
maxReconnectAttempts: 5, export default sharedSocketInstance;
reconnectInterval: 1000,
heartbeatInterval: 30000,
enableAutoReconnect: true
});
// Export knowledgeSocketIO as default for backward compatibility
export default knowledgeSocketIO;

View File

@@ -13,7 +13,8 @@
* - Proper session identification * - Proper session identification
*/ */
import { WebSocketService, WebSocketState } from './socketIOService'; import { WebSocketState } from './socketIOService';
import sharedSocketInstance from './socketIOService';
export interface Task { export interface Task {
id: string; id: string;
@@ -38,7 +39,7 @@ export interface TaskSocketEvents {
class TaskSocketService { class TaskSocketService {
private static instance: TaskSocketService | null = null; private static instance: TaskSocketService | null = null;
private socketService: WebSocketService; private socketService: typeof sharedSocketInstance;
private currentProjectId: string | null = null; private currentProjectId: string | null = null;
private eventHandlers: Map<string, TaskSocketEvents> = new Map(); private eventHandlers: Map<string, TaskSocketEvents> = new Map();
private connectionPromise: Promise<void> | null = null; private connectionPromise: Promise<void> | null = null;
@@ -47,13 +48,11 @@ class TaskSocketService {
private connectionCooldown = 1000; // 1 second cooldown between connection attempts private connectionCooldown = 1000; // 1 second cooldown between connection attempts
private constructor() { private constructor() {
this.socketService = new WebSocketService({ // Use the shared socket instance instead of creating a new one
maxReconnectAttempts: 5, this.socketService = sharedSocketInstance;
reconnectInterval: 1000,
heartbeatInterval: 30000, // Enable operation tracking for echo suppression
enableAutoReconnect: true, this.socketService.enableOperationTracking();
enableHeartbeat: true
});
// Set up global event handlers // Set up global event handlers
this.setupGlobalHandlers(); this.setupGlobalHandlers();
@@ -191,7 +190,7 @@ class TaskSocketService {
const joinSuccess = this.socketService.send({ const joinSuccess = this.socketService.send({
type: 'join_project', type: 'join_project',
project_id: projectId project_id: projectId
}); }, true); // Enable operation tracking
if (!joinSuccess) { if (!joinSuccess) {
throw new Error('Failed to send join_project message'); throw new Error('Failed to send join_project message');
@@ -214,7 +213,7 @@ class TaskSocketService {
this.socketService.send({ this.socketService.send({
type: 'leave_project', type: 'leave_project',
project_id: this.currentProjectId project_id: this.currentProjectId
}); }, true); // Enable operation tracking
this.currentProjectId = null; this.currentProjectId = null;
} }

View File

@@ -7,7 +7,7 @@ Mirrors the functionality of the original manage_task tool but with individual t
import json import json
import logging import logging
from typing import Any, Dict, List, Optional, TypedDict from typing import Any, Dict, List, Optional
from urllib.parse import urljoin from urllib.parse import urljoin
import httpx import httpx
@@ -20,19 +20,6 @@ from src.server.config.service_discovery import get_api_url
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class TaskUpdateFields(TypedDict, total=False):
"""Valid fields that can be updated on a task."""
title: str
description: str
status: str # "todo" | "doing" | "review" | "done"
assignee: str # "User" | "Archon" | "AI IDE Agent" | "prp-executor" | "prp-validator"
task_order: int # 0-100, higher = more priority
feature: Optional[str]
sources: Optional[List[Dict[str, str]]]
code_examples: Optional[List[Dict[str, str]]]
def register_task_tools(mcp: FastMCP): def register_task_tools(mcp: FastMCP):
"""Register individual task management tools with the MCP server.""" """Register individual task management tools with the MCP server."""
@@ -315,26 +302,66 @@ def register_task_tools(mcp: FastMCP):
async def update_task( async def update_task(
ctx: Context, ctx: Context,
task_id: str, task_id: str,
update_fields: TaskUpdateFields, title: Optional[str] = None,
description: Optional[str] = None,
status: Optional[str] = None,
assignee: Optional[str] = None,
task_order: Optional[int] = None,
feature: Optional[str] = None,
sources: Optional[List[Dict[str, str]]] = None,
code_examples: Optional[List[Dict[str, str]]] = None,
) -> str: ) -> str:
""" """
Update a task's properties. Update a task's properties.
Args: Args:
task_id: UUID of the task to update task_id: UUID of the task to update
update_fields: Dict of fields to update (e.g., {"status": "doing", "assignee": "AI IDE Agent"}) title: New task title (optional)
description: New task description (optional)
status: New status - "todo" | "doing" | "review" | "done" (optional)
assignee: New assignee (optional)
task_order: New priority order (optional)
feature: New feature label (optional)
sources: New source references (optional)
code_examples: New code examples (optional)
Returns: Returns:
JSON with updated task details JSON with updated task details
Examples: Examples:
update_task(task_id="uuid", update_fields={"status": "doing"}) update_task(task_id="uuid", status="doing")
update_task(task_id="uuid", update_fields={"title": "New Title", "description": "Updated description"}) update_task(task_id="uuid", title="New Title", description="Updated description")
""" """
try: try:
api_url = get_api_url() api_url = get_api_url()
timeout = get_default_timeout() timeout = get_default_timeout()
# Build update_fields dict from provided parameters
update_fields = {}
if title is not None:
update_fields["title"] = title
if description is not None:
update_fields["description"] = description
if status is not None:
update_fields["status"] = status
if assignee is not None:
update_fields["assignee"] = assignee
if task_order is not None:
update_fields["task_order"] = task_order
if feature is not None:
update_fields["feature"] = feature
if sources is not None:
update_fields["sources"] = sources
if code_examples is not None:
update_fields["code_examples"] = code_examples
if not update_fields:
return MCPErrorFormatter.format_error(
error_type="validation_error",
message="No fields provided to update",
suggestion="Provide at least one field to update",
)
async with httpx.AsyncClient(timeout=timeout) as client: async with httpx.AsyncClient(timeout=timeout) as client:
response = await client.put( response = await client.put(
urljoin(api_url, f"/api/tasks/{task_id}"), json=update_fields urljoin(api_url, f"/api/tasks/{task_id}"), json=update_fields