diff --git a/.gitignore b/.gitignore index 7fe87369..f7e4711e 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ Desktop.ini .secrets secrets/ credentials/ +/config.json # =========================== # IDE & Editors diff --git a/apps/frontend/src/main/__tests__/ipc-handlers.test.ts b/apps/frontend/src/main/__tests__/ipc-handlers.test.ts index af333645..c969ca33 100644 --- a/apps/frontend/src/main/__tests__/ipc-handlers.test.ts +++ b/apps/frontend/src/main/__tests__/ipc-handlers.test.ts @@ -520,7 +520,8 @@ describe('IPC Handlers', { timeout: 15000 }, () => { expect(mockMainWindow.webContents.send).toHaveBeenCalledWith( 'task:log', 'task-1', - 'Test log message' + 'Test log message', + undefined // projectId is undefined when task not found ); }); @@ -533,7 +534,8 @@ describe('IPC Handlers', { timeout: 15000 }, () => { expect(mockMainWindow.webContents.send).toHaveBeenCalledWith( 'task:error', 'task-1', - 'Test error message' + 'Test error message', + undefined // projectId is undefined when task not found ); }); @@ -557,7 +559,8 @@ describe('IPC Handlers', { timeout: 15000 }, () => { expect(mockMainWindow.webContents.send).toHaveBeenCalledWith( 'task:statusChange', 'task-1', - 'human_review' + 'human_review', + expect.any(String) // projectId for multi-project filtering ); }); }); diff --git a/apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts b/apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts index 9550b1ef..1c6b350b 100644 --- a/apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts @@ -17,6 +17,7 @@ import { projectStore } from '../project-store'; import { notificationService } from '../notification-service'; import { persistPlanStatusSync, getPlanPath } from './task/plan-file-utils'; import { findTaskWorktree } from '../worktree-paths'; +import { findTaskAndProject } from './task/shared'; /** @@ -33,14 +34,18 @@ export function registerAgenteventsHandlers( agentManager.on('log', (taskId: string, log: string) => { const mainWindow = getMainWindow(); if (mainWindow) { - mainWindow.webContents.send(IPC_CHANNELS.TASK_LOG, taskId, log); + // Include projectId for multi-project filtering (issue #723) + const { project } = findTaskAndProject(taskId); + mainWindow.webContents.send(IPC_CHANNELS.TASK_LOG, taskId, log, project?.id); } }); agentManager.on('error', (taskId: string, error: string) => { const mainWindow = getMainWindow(); if (mainWindow) { - mainWindow.webContents.send(IPC_CHANNELS.TASK_ERROR, taskId, error); + // Include projectId for multi-project filtering (issue #723) + const { project } = findTaskAndProject(taskId); + mainWindow.webContents.send(IPC_CHANNELS.TASK_ERROR, taskId, error, project?.id); } }); @@ -63,11 +68,15 @@ export function registerAgenteventsHandlers( agentManager.on('exit', (taskId: string, code: number | null, processType: ProcessType) => { const mainWindow = getMainWindow(); if (mainWindow) { + // Get project info early for multi-project filtering (issue #723) + const { project: exitProject } = findTaskAndProject(taskId); + const exitProjectId = exitProject?.id; + // Send final plan state to renderer BEFORE unwatching // This ensures the renderer has the final subtask data (fixes 0/0 subtask bug) const finalPlan = fileWatcher.getCurrentPlan(taskId); if (finalPlan) { - mainWindow.webContents.send(IPC_CHANNELS.TASK_PROGRESS, taskId, finalPlan); + mainWindow.webContents.send(IPC_CHANNELS.TASK_PROGRESS, taskId, finalPlan, exitProjectId); } fileWatcher.unwatch(taskId); @@ -138,30 +147,34 @@ export function registerAgenteventsHandlers( if (code === 0) { notificationService.notifyReviewNeeded(taskTitle, project.id, taskId); - + // Fallback: Ensure status is updated even if COMPLETE phase event was missed // This prevents tasks from getting stuck in ai_review status // Uses inverted logic to also handle tasks with no subtasks (treats them as complete) const isActiveStatus = task.status === 'in_progress' || task.status === 'ai_review'; - const hasIncompleteSubtasks = task.subtasks && task.subtasks.length > 0 && + const hasIncompleteSubtasks = task.subtasks && task.subtasks.length > 0 && task.subtasks.some((s) => s.status !== 'completed'); - + if (isActiveStatus && !hasIncompleteSubtasks) { console.warn(`[Task ${taskId}] Fallback: Moving to human_review (process exited successfully)`); persistStatus('human_review'); + // Include projectId for multi-project filtering (issue #723) mainWindow.webContents.send( IPC_CHANNELS.TASK_STATUS_CHANGE, taskId, - 'human_review' as TaskStatus + 'human_review' as TaskStatus, + projectId ); } } else { notificationService.notifyTaskFailed(taskTitle, project.id, taskId); persistStatus('human_review'); + // Include projectId for multi-project filtering (issue #723) mainWindow.webContents.send( IPC_CHANNELS.TASK_STATUS_CHANGE, taskId, - 'human_review' as TaskStatus + 'human_review' as TaskStatus, + projectId ); } } @@ -174,7 +187,12 @@ export function registerAgenteventsHandlers( agentManager.on('execution-progress', (taskId: string, progress: ExecutionProgressData) => { const mainWindow = getMainWindow(); if (mainWindow) { - mainWindow.webContents.send(IPC_CHANNELS.TASK_EXECUTION_PROGRESS, taskId, progress); + // Use shared helper to find task and project (issue #723 - deduplicate lookup) + const { task, project } = findTaskAndProject(taskId); + const taskProjectId = project?.id; + + // Include projectId in execution progress event for multi-project filtering + mainWindow.webContents.send(IPC_CHANNELS.TASK_EXECUTION_PROGRESS, taskId, progress, taskProjectId); const phaseToStatus: Record = { 'idle': null, @@ -188,10 +206,12 @@ export function registerAgenteventsHandlers( const newStatus = phaseToStatus[progress.phase]; if (newStatus) { + // Include projectId in status change event for multi-project filtering mainWindow.webContents.send( IPC_CHANNELS.TASK_STATUS_CHANGE, taskId, - newStatus + newStatus, + taskProjectId ); // CRITICAL: Persist status to plan file(s) to prevent flip-flop on task list refresh @@ -200,37 +220,31 @@ export function registerAgenteventsHandlers( // Uses shared utility with locking to prevent race conditions. // IMPORTANT: We persist to BOTH main project AND worktree (if exists) to ensure // consistency, since getTasks() prefers the worktree version. - try { - const projects = projectStore.getProjects(); - for (const p of projects) { - const tasks = projectStore.getTasks(p.id); - const task = tasks.find((t) => t.id === taskId || t.specId === taskId); - if (task) { - // Persist to main project plan file - const mainPlanPath = getPlanPath(p, task); - persistPlanStatusSync(mainPlanPath, newStatus, p.id); + if (task && project) { + try { + // Persist to main project plan file + const mainPlanPath = getPlanPath(project, task); + persistPlanStatusSync(mainPlanPath, newStatus, project.id); - // Also persist to worktree plan file if it exists - // This ensures consistency since getTasks() prefers worktree version - const worktreePath = findTaskWorktree(p.path, task.specId); - if (worktreePath) { - const specsBaseDir = getSpecsDir(p.autoBuildPath); - const worktreePlanPath = path.join( - worktreePath, - specsBaseDir, - task.specId, - AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN - ); - if (existsSync(worktreePlanPath)) { - persistPlanStatusSync(worktreePlanPath, newStatus, p.id); - } + // Also persist to worktree plan file if it exists + // This ensures consistency since getTasks() prefers worktree version + const worktreePath = findTaskWorktree(project.path, task.specId); + if (worktreePath) { + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const worktreePlanPath = path.join( + worktreePath, + specsBaseDir, + task.specId, + AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN + ); + if (existsSync(worktreePlanPath)) { + persistPlanStatusSync(worktreePlanPath, newStatus, project.id); } - break; } + } catch (err) { + // Ignore persistence errors - UI will still work, just might flip on refresh + console.warn('[execution-progress] Could not persist status:', err); } - } catch (err) { - // Ignore persistence errors - UI will still work, just might flip on refresh - console.warn('[execution-progress] Could not persist status:', err); } } } @@ -243,14 +257,18 @@ export function registerAgenteventsHandlers( fileWatcher.on('progress', (taskId: string, plan: ImplementationPlan) => { const mainWindow = getMainWindow(); if (mainWindow) { - mainWindow.webContents.send(IPC_CHANNELS.TASK_PROGRESS, taskId, plan); + // Use shared helper to find project (issue #723 - deduplicate lookup) + const { project } = findTaskAndProject(taskId); + mainWindow.webContents.send(IPC_CHANNELS.TASK_PROGRESS, taskId, plan, project?.id); } }); fileWatcher.on('error', (taskId: string, error: string) => { const mainWindow = getMainWindow(); if (mainWindow) { - mainWindow.webContents.send(IPC_CHANNELS.TASK_ERROR, taskId, error); + // Include projectId for multi-project filtering (issue #723) + const { project } = findTaskAndProject(taskId); + mainWindow.webContents.send(IPC_CHANNELS.TASK_ERROR, taskId, error, project?.id); } }); } diff --git a/apps/frontend/src/preload/api/task-api.ts b/apps/frontend/src/preload/api/task-api.ts index 6049f85b..c2f3a678 100644 --- a/apps/frontend/src/preload/api/task-api.ts +++ b/apps/frontend/src/preload/api/task-api.ts @@ -59,12 +59,13 @@ export interface TaskAPI { unarchiveTasks: (projectId: string, taskIds: string[]) => Promise>; // Task Event Listeners - onTaskProgress: (callback: (taskId: string, plan: ImplementationPlan) => void) => () => void; - onTaskError: (callback: (taskId: string, error: string) => void) => () => void; - onTaskLog: (callback: (taskId: string, log: string) => void) => () => void; - onTaskStatusChange: (callback: (taskId: string, status: TaskStatus) => void) => () => void; + // Note: projectId is optional for backward compatibility - events without projectId will still work + onTaskProgress: (callback: (taskId: string, plan: ImplementationPlan, projectId?: string) => void) => () => void; + onTaskError: (callback: (taskId: string, error: string, projectId?: string) => void) => () => void; + onTaskLog: (callback: (taskId: string, log: string, projectId?: string) => void) => () => void; + onTaskStatusChange: (callback: (taskId: string, status: TaskStatus, projectId?: string) => void) => () => void; onTaskExecutionProgress: ( - callback: (taskId: string, progress: import('../../shared/types').ExecutionProgress) => void + callback: (taskId: string, progress: import('../../shared/types').ExecutionProgress, projectId?: string) => void ) => () => void; // Task Phase Logs @@ -161,14 +162,15 @@ export const createTaskAPI = (): TaskAPI => ({ // Task Event Listeners onTaskProgress: ( - callback: (taskId: string, plan: ImplementationPlan) => void + callback: (taskId: string, plan: ImplementationPlan, projectId?: string) => void ): (() => void) => { const handler = ( _event: Electron.IpcRendererEvent, taskId: string, - plan: ImplementationPlan + plan: ImplementationPlan, + projectId?: string ): void => { - callback(taskId, plan); + callback(taskId, plan, projectId); }; ipcRenderer.on(IPC_CHANNELS.TASK_PROGRESS, handler); return () => { @@ -177,14 +179,15 @@ export const createTaskAPI = (): TaskAPI => ({ }, onTaskError: ( - callback: (taskId: string, error: string) => void + callback: (taskId: string, error: string, projectId?: string) => void ): (() => void) => { const handler = ( _event: Electron.IpcRendererEvent, taskId: string, - error: string + error: string, + projectId?: string ): void => { - callback(taskId, error); + callback(taskId, error, projectId); }; ipcRenderer.on(IPC_CHANNELS.TASK_ERROR, handler); return () => { @@ -193,14 +196,15 @@ export const createTaskAPI = (): TaskAPI => ({ }, onTaskLog: ( - callback: (taskId: string, log: string) => void + callback: (taskId: string, log: string, projectId?: string) => void ): (() => void) => { const handler = ( _event: Electron.IpcRendererEvent, taskId: string, - log: string + log: string, + projectId?: string ): void => { - callback(taskId, log); + callback(taskId, log, projectId); }; ipcRenderer.on(IPC_CHANNELS.TASK_LOG, handler); return () => { @@ -209,14 +213,15 @@ export const createTaskAPI = (): TaskAPI => ({ }, onTaskStatusChange: ( - callback: (taskId: string, status: TaskStatus) => void + callback: (taskId: string, status: TaskStatus, projectId?: string) => void ): (() => void) => { const handler = ( _event: Electron.IpcRendererEvent, taskId: string, - status: TaskStatus + status: TaskStatus, + projectId?: string ): void => { - callback(taskId, status); + callback(taskId, status, projectId); }; ipcRenderer.on(IPC_CHANNELS.TASK_STATUS_CHANGE, handler); return () => { @@ -225,14 +230,15 @@ export const createTaskAPI = (): TaskAPI => ({ }, onTaskExecutionProgress: ( - callback: (taskId: string, progress: import('../../shared/types').ExecutionProgress) => void + callback: (taskId: string, progress: import('../../shared/types').ExecutionProgress, projectId?: string) => void ): (() => void) => { const handler = ( _event: Electron.IpcRendererEvent, taskId: string, - progress: import('../../shared/types').ExecutionProgress + progress: import('../../shared/types').ExecutionProgress, + projectId?: string ): void => { - callback(taskId, progress); + callback(taskId, progress, projectId); }; ipcRenderer.on(IPC_CHANNELS.TASK_EXECUTION_PROGRESS, handler); return () => { diff --git a/apps/frontend/src/renderer/hooks/useIpc.ts b/apps/frontend/src/renderer/hooks/useIpc.ts index 7e0f2f5e..6539dd01 100644 --- a/apps/frontend/src/renderer/hooks/useIpc.ts +++ b/apps/frontend/src/renderer/hooks/useIpc.ts @@ -3,6 +3,7 @@ import { unstable_batchedUpdates } from 'react-dom'; import { useTaskStore } from '../stores/task-store'; import { useRoadmapStore } from '../stores/roadmap-store'; import { useRateLimitStore } from '../stores/rate-limit-store'; +import { useProjectStore } from '../stores/project-store'; import type { ImplementationPlan, TaskStatus, RoadmapGenerationStatus, Roadmap, ExecutionProgress, RateLimitInfo, SDKRateLimitInfo } from '../../shared/types'; /** @@ -111,6 +112,21 @@ function queueUpdate(taskId: string, update: BatchedUpdate): void { } } +/** + * Check if a task event is for the currently selected project. + * This prevents multi-project interference where events from one project's + * running task incorrectly update another project's task state (issue #723). + * Handles backward compatibility and no-project-selected cases. + */ +function isTaskForCurrentProject(eventProjectId?: string): boolean { + // If no projectId provided (backward compatibility), accept the event + if (!eventProjectId) return true; + const currentProjectId = useProjectStore.getState().selectedProjectId; + // If no project selected, accept the event + if (!currentProjectId) return true; + return currentProjectId === eventProjectId; +} + /** * Hook to set up IPC event listeners for task updates */ @@ -129,13 +145,17 @@ export function useIpcListeners(): void { useEffect(() => { // Set up listeners with batched updates const cleanupProgress = window.electronAPI.onTaskProgress( - (taskId: string, plan: ImplementationPlan) => { + (taskId: string, plan: ImplementationPlan, projectId?: string) => { + // Filter by project to prevent multi-project interference + if (!isTaskForCurrentProject(projectId)) return; queueUpdate(taskId, { plan }); } ); const cleanupError = window.electronAPI.onTaskError( - (taskId: string, error: string) => { + (taskId: string, error: string, projectId?: string) => { + // Filter by project to prevent multi-project interference (issue #723) + if (!isTaskForCurrentProject(projectId)) return; // Errors are not batched - show immediately setError(`Task ${taskId}: ${error}`); appendLog(taskId, `[ERROR] ${error}`); @@ -143,20 +163,28 @@ export function useIpcListeners(): void { ); const cleanupLog = window.electronAPI.onTaskLog( - (taskId: string, log: string) => { + (taskId: string, log: string, projectId?: string) => { + // Filter by project to prevent multi-project interference (issue #723) + if (!isTaskForCurrentProject(projectId)) return; // Logs are now batched to reduce state updates (was causing 100+ updates/sec) queueUpdate(taskId, { logs: [log] }); } ); const cleanupStatus = window.electronAPI.onTaskStatusChange( - (taskId: string, status: TaskStatus) => { + (taskId: string, status: TaskStatus, projectId?: string) => { + // Filter by project to prevent multi-project interference + if (!isTaskForCurrentProject(projectId)) return; queueUpdate(taskId, { status }); } ); const cleanupExecutionProgress = window.electronAPI.onTaskExecutionProgress( - (taskId: string, progress: ExecutionProgress) => { + (taskId: string, progress: ExecutionProgress, projectId?: string) => { + // Filter by project to prevent multi-project interference + // This is the critical fix for issue #723 - without this check, + // execution progress from Project A's task could update Project B's UI + if (!isTaskForCurrentProject(projectId)) return; queueUpdate(taskId, { progress }); } );