fix(multi-project): filter task IPC events by project to prevent cross-project interference (#723) (#775)
* fix(multi-project): filter task IPC events by project to prevent cross-project interference [ACS-723] When multiple projects had tasks running simultaneously, starting a task in Project B would cause Project A's running task to appear "idle" because IPC events were globally broadcast without project context. Changes: - Add projectId to execution-progress, status-change, and progress IPC events - Filter events in renderer by comparing event projectId with selected project - Maintain backward compatibility - events without projectId still accepted Fixes #723 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address PR feedback - deduplicate code and add projectId to all events - Use existing findTaskAndProject helper instead of inline loops - Add projectId to log and error events for complete filtering - Extract isTaskForCurrentProject helper to module scope - Update tests to expect new projectId parameter 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: CodeRabbit <coderabbit@users.noreply.github.com> * fix: add projectId to exit handler TASK_PROGRESS event The TASK_PROGRESS event sent in the exit handler was missing the projectId parameter, which could cause cross-project interference when a task exits while viewing a different project. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * chore: remove config.json and add to gitignore - Remove accidentally committed config.json from repository - Add /config.json to .gitignore to prevent future accidental commits 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: CodeRabbit <coderabbit@users.noreply.github.com> Co-authored-by: Alex <63423455+AlexMadera@users.noreply.github.com>
This commit is contained in:
@@ -23,6 +23,7 @@ Desktop.ini
|
||||
.secrets
|
||||
secrets/
|
||||
credentials/
|
||||
/config.json
|
||||
|
||||
# ===========================
|
||||
# IDE & Editors
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
@@ -149,19 +158,23 @@ export function registerAgenteventsHandlers(
|
||||
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<string, TaskStatus | null> = {
|
||||
'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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -59,12 +59,13 @@ export interface TaskAPI {
|
||||
unarchiveTasks: (projectId: string, taskIds: string[]) => Promise<IPCResult<boolean>>;
|
||||
|
||||
// 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 () => {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user