mirror of
https://github.com/coleam00/Archon.git
synced 2026-01-01 04:09:08 -05:00
Fixed the socket optimistic updates. And the MCP for update task.
This commit is contained in:
@@ -511,33 +511,50 @@ export const TasksTab = ({
|
||||
debouncedPersistBatchReorder(tasksToUpdate);
|
||||
}, [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']) => {
|
||||
console.log(`[TasksTab] Attempting to move task ${taskId} to new status: ${newStatus}`);
|
||||
|
||||
const movingTask = tasks.find(task => task.id === taskId);
|
||||
if (!movingTask) {
|
||||
console.warn(`[TasksTab] Task ${taskId} not found for move operation.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const oldStatus = movingTask.status;
|
||||
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}`);
|
||||
|
||||
// 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 {
|
||||
const movingTask = tasks.find(task => task.id === taskId);
|
||||
if (!movingTask) {
|
||||
console.warn(`[TasksTab] Task ${taskId} not found for move operation.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const oldStatus = movingTask.status;
|
||||
const newOrder = getNextOrderForStatus(newStatus);
|
||||
|
||||
console.log(`[TasksTab] Moving task ${movingTask.title} from ${oldStatus} to ${newStatus} with order ${newOrder}`);
|
||||
|
||||
// Update the task with new status and order
|
||||
// Then update the backend
|
||||
await projectService.updateTask(taskId, {
|
||||
status: mapUIStatusToDBStatus(newStatus),
|
||||
task_order: newOrder
|
||||
});
|
||||
console.log(`[TasksTab] Successfully updated task ${taskId} status in backend.`);
|
||||
|
||||
// Don't update local state immediately - let socket handle it
|
||||
console.log(`[TasksTab] Waiting for socket update for task ${taskId}.`);
|
||||
// Socket will confirm the update, but UI is already updated
|
||||
|
||||
} 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'}`);
|
||||
}
|
||||
};
|
||||
@@ -557,15 +574,50 @@ export const TasksTab = ({
|
||||
if (!taskToDelete) return;
|
||||
|
||||
try {
|
||||
// Delete (actually archives) the task - backend will emit socket event
|
||||
await projectService.deleteTask(taskToDelete.id);
|
||||
console.log(`[TasksTab] Task ${taskToDelete.id} archival sent to backend`);
|
||||
// Add to recently deleted cache to prevent race conditions
|
||||
setRecentlyDeletedIds(prev => new Set(prev).add(taskToDelete.id));
|
||||
|
||||
// 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) {
|
||||
console.error('Failed to archive task:', error);
|
||||
// Note: The toast notification for deletion is now handled by TaskBoardView and TaskTableView
|
||||
console.error('Failed to delete task:', error);
|
||||
|
||||
// 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 {
|
||||
setTaskToDelete(null);
|
||||
setShowDeleteConfirm(false);
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* 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 { WebSocketState } from '../services/socketIOService';
|
||||
|
||||
@@ -36,6 +36,10 @@ export function useTaskSocket(options: UseTaskSocketOptions) {
|
||||
const componentIdRef = useRef<string>(`task-socket-${Math.random().toString(36).substring(7)}`);
|
||||
const currentProjectIdRef = useRef<string | null>(null);
|
||||
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
|
||||
const memoizedHandlers = useCallback((): Partial<TaskSocketEvents> => {
|
||||
@@ -58,6 +62,44 @@ export function useTaskSocket(options: UseTaskSocketOptions) {
|
||||
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
|
||||
useEffect(() => {
|
||||
if (!projectId || isInitializedRef.current) return;
|
||||
@@ -65,6 +107,7 @@ export function useTaskSocket(options: UseTaskSocketOptions) {
|
||||
const initializeConnection = async () => {
|
||||
try {
|
||||
console.log(`[USE_TASK_SOCKET] Initializing connection for project: ${projectId}`);
|
||||
setConnectionState(WebSocketState.CONNECTING);
|
||||
|
||||
// Register handlers first
|
||||
taskSocketService.registerHandlers(componentIdRef.current, memoizedHandlers());
|
||||
@@ -76,8 +119,14 @@ export function useTaskSocket(options: UseTaskSocketOptions) {
|
||||
isInitializedRef.current = true;
|
||||
console.log(`[USE_TASK_SOCKET] Successfully initialized for project: ${projectId}`);
|
||||
|
||||
// Update connection state after successful connection
|
||||
setIsConnected(taskSocketService.isConnected());
|
||||
setConnectionState(taskSocketService.getConnectionState());
|
||||
|
||||
} catch (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 () => {
|
||||
try {
|
||||
setConnectionState(WebSocketState.CONNECTING);
|
||||
|
||||
// Update handlers for new project
|
||||
taskSocketService.registerHandlers(componentIdRef.current, memoizedHandlers());
|
||||
|
||||
@@ -112,8 +163,14 @@ export function useTaskSocket(options: UseTaskSocketOptions) {
|
||||
currentProjectIdRef.current = projectId;
|
||||
console.log(`[USE_TASK_SOCKET] Successfully switched to project: ${projectId}`);
|
||||
|
||||
// Update connection state
|
||||
setIsConnected(taskSocketService.isConnected());
|
||||
setConnectionState(taskSocketService.getConnectionState());
|
||||
|
||||
} catch (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 {
|
||||
isConnected: taskSocketService.isConnected(),
|
||||
connectionState: taskSocketService.getConnectionState(),
|
||||
isConnected, // Now reactive!
|
||||
connectionState, // Now reactive!
|
||||
reconnect: taskSocketService.reconnect.bind(taskSocketService),
|
||||
getCurrentProjectId: taskSocketService.getCurrentProjectId.bind(taskSocketService)
|
||||
};
|
||||
|
||||
@@ -678,28 +678,15 @@ export function createWebSocketService(config?: WebSocketConfig): WebSocketServi
|
||||
return new WebSocketService(config);
|
||||
}
|
||||
|
||||
// Create SEPARATE WebSocket instances for different features
|
||||
// This prevents a failure in one feature (like a long crawl) from breaking the entire site
|
||||
export const knowledgeSocketIO = new WebSocketService({
|
||||
maxReconnectAttempts: 10, // More attempts for crawls that can take a long time
|
||||
reconnectInterval: 2000,
|
||||
heartbeatInterval: 30000,
|
||||
enableAutoReconnect: true
|
||||
});
|
||||
// Create a SINGLE shared WebSocket instance to prevent multiple connections
|
||||
// This fixes the socket disconnection issue when switching tabs
|
||||
const sharedSocketInstance = new WebSocketService();
|
||||
|
||||
export const taskUpdateSocketIO = new WebSocketService({
|
||||
maxReconnectAttempts: 5,
|
||||
reconnectInterval: 1000,
|
||||
heartbeatInterval: 30000,
|
||||
enableAutoReconnect: true
|
||||
});
|
||||
// Export the SAME instance with different names for backward compatibility
|
||||
// This ensures only ONE Socket.IO connection is created and shared across all features
|
||||
export const knowledgeSocketIO = sharedSocketInstance;
|
||||
export const taskUpdateSocketIO = sharedSocketInstance;
|
||||
export const projectListSocketIO = sharedSocketInstance;
|
||||
|
||||
export const projectListSocketIO = new WebSocketService({
|
||||
maxReconnectAttempts: 5,
|
||||
reconnectInterval: 1000,
|
||||
heartbeatInterval: 30000,
|
||||
enableAutoReconnect: true
|
||||
});
|
||||
|
||||
// Export knowledgeSocketIO as default for backward compatibility
|
||||
export default knowledgeSocketIO;
|
||||
// Export as default for new code
|
||||
export default sharedSocketInstance;
|
||||
@@ -13,7 +13,8 @@
|
||||
* - Proper session identification
|
||||
*/
|
||||
|
||||
import { WebSocketService, WebSocketState } from './socketIOService';
|
||||
import { WebSocketState } from './socketIOService';
|
||||
import sharedSocketInstance from './socketIOService';
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
@@ -38,7 +39,7 @@ export interface TaskSocketEvents {
|
||||
|
||||
class TaskSocketService {
|
||||
private static instance: TaskSocketService | null = null;
|
||||
private socketService: WebSocketService;
|
||||
private socketService: typeof sharedSocketInstance;
|
||||
private currentProjectId: string | null = null;
|
||||
private eventHandlers: Map<string, TaskSocketEvents> = new Map();
|
||||
private connectionPromise: Promise<void> | null = null;
|
||||
@@ -47,13 +48,11 @@ class TaskSocketService {
|
||||
private connectionCooldown = 1000; // 1 second cooldown between connection attempts
|
||||
|
||||
private constructor() {
|
||||
this.socketService = new WebSocketService({
|
||||
maxReconnectAttempts: 5,
|
||||
reconnectInterval: 1000,
|
||||
heartbeatInterval: 30000,
|
||||
enableAutoReconnect: true,
|
||||
enableHeartbeat: true
|
||||
});
|
||||
// Use the shared socket instance instead of creating a new one
|
||||
this.socketService = sharedSocketInstance;
|
||||
|
||||
// Enable operation tracking for echo suppression
|
||||
this.socketService.enableOperationTracking();
|
||||
|
||||
// Set up global event handlers
|
||||
this.setupGlobalHandlers();
|
||||
@@ -191,7 +190,7 @@ class TaskSocketService {
|
||||
const joinSuccess = this.socketService.send({
|
||||
type: 'join_project',
|
||||
project_id: projectId
|
||||
});
|
||||
}, true); // Enable operation tracking
|
||||
|
||||
if (!joinSuccess) {
|
||||
throw new Error('Failed to send join_project message');
|
||||
@@ -214,7 +213,7 @@ class TaskSocketService {
|
||||
this.socketService.send({
|
||||
type: 'leave_project',
|
||||
project_id: this.currentProjectId
|
||||
});
|
||||
}, true); // Enable operation tracking
|
||||
|
||||
this.currentProjectId = null;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ Mirrors the functionality of the original manage_task tool but with individual t
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional, TypedDict
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import httpx
|
||||
@@ -20,19 +20,6 @@ from src.server.config.service_discovery import get_api_url
|
||||
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):
|
||||
"""Register individual task management tools with the MCP server."""
|
||||
|
||||
@@ -315,26 +302,66 @@ def register_task_tools(mcp: FastMCP):
|
||||
async def update_task(
|
||||
ctx: Context,
|
||||
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:
|
||||
"""
|
||||
Update a task's properties.
|
||||
|
||||
Args:
|
||||
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:
|
||||
JSON with updated task details
|
||||
|
||||
Examples:
|
||||
update_task(task_id="uuid", update_fields={"status": "doing"})
|
||||
update_task(task_id="uuid", update_fields={"title": "New Title", "description": "Updated description"})
|
||||
update_task(task_id="uuid", status="doing")
|
||||
update_task(task_id="uuid", title="New Title", description="Updated description")
|
||||
"""
|
||||
try:
|
||||
api_url = get_api_url()
|
||||
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:
|
||||
response = await client.put(
|
||||
urljoin(api_url, f"/api/tasks/{task_id}"), json=update_fields
|
||||
|
||||
Reference in New Issue
Block a user