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:
Andy
2026-01-07 14:10:31 +01:00
committed by GitHub
parent 061411d79a
commit cc78d7aed0
5 changed files with 123 additions and 67 deletions
+1
View File
@@ -23,6 +23,7 @@ Desktop.ini
.secrets .secrets
secrets/ secrets/
credentials/ credentials/
/config.json
# =========================== # ===========================
# IDE & Editors # IDE & Editors
@@ -520,7 +520,8 @@ describe('IPC Handlers', { timeout: 15000 }, () => {
expect(mockMainWindow.webContents.send).toHaveBeenCalledWith( expect(mockMainWindow.webContents.send).toHaveBeenCalledWith(
'task:log', 'task:log',
'task-1', '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( expect(mockMainWindow.webContents.send).toHaveBeenCalledWith(
'task:error', 'task:error',
'task-1', '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( expect(mockMainWindow.webContents.send).toHaveBeenCalledWith(
'task:statusChange', 'task:statusChange',
'task-1', '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 { notificationService } from '../notification-service';
import { persistPlanStatusSync, getPlanPath } from './task/plan-file-utils'; import { persistPlanStatusSync, getPlanPath } from './task/plan-file-utils';
import { findTaskWorktree } from '../worktree-paths'; import { findTaskWorktree } from '../worktree-paths';
import { findTaskAndProject } from './task/shared';
/** /**
@@ -33,14 +34,18 @@ export function registerAgenteventsHandlers(
agentManager.on('log', (taskId: string, log: string) => { agentManager.on('log', (taskId: string, log: string) => {
const mainWindow = getMainWindow(); const mainWindow = getMainWindow();
if (mainWindow) { 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) => { agentManager.on('error', (taskId: string, error: string) => {
const mainWindow = getMainWindow(); const mainWindow = getMainWindow();
if (mainWindow) { 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) => { agentManager.on('exit', (taskId: string, code: number | null, processType: ProcessType) => {
const mainWindow = getMainWindow(); const mainWindow = getMainWindow();
if (mainWindow) { 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 // Send final plan state to renderer BEFORE unwatching
// This ensures the renderer has the final subtask data (fixes 0/0 subtask bug) // This ensures the renderer has the final subtask data (fixes 0/0 subtask bug)
const finalPlan = fileWatcher.getCurrentPlan(taskId); const finalPlan = fileWatcher.getCurrentPlan(taskId);
if (finalPlan) { if (finalPlan) {
mainWindow.webContents.send(IPC_CHANNELS.TASK_PROGRESS, taskId, finalPlan); mainWindow.webContents.send(IPC_CHANNELS.TASK_PROGRESS, taskId, finalPlan, exitProjectId);
} }
fileWatcher.unwatch(taskId); fileWatcher.unwatch(taskId);
@@ -138,30 +147,34 @@ export function registerAgenteventsHandlers(
if (code === 0) { if (code === 0) {
notificationService.notifyReviewNeeded(taskTitle, project.id, taskId); notificationService.notifyReviewNeeded(taskTitle, project.id, taskId);
// Fallback: Ensure status is updated even if COMPLETE phase event was missed // Fallback: Ensure status is updated even if COMPLETE phase event was missed
// This prevents tasks from getting stuck in ai_review status // This prevents tasks from getting stuck in ai_review status
// Uses inverted logic to also handle tasks with no subtasks (treats them as complete) // 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 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'); task.subtasks.some((s) => s.status !== 'completed');
if (isActiveStatus && !hasIncompleteSubtasks) { if (isActiveStatus && !hasIncompleteSubtasks) {
console.warn(`[Task ${taskId}] Fallback: Moving to human_review (process exited successfully)`); console.warn(`[Task ${taskId}] Fallback: Moving to human_review (process exited successfully)`);
persistStatus('human_review'); persistStatus('human_review');
// Include projectId for multi-project filtering (issue #723)
mainWindow.webContents.send( mainWindow.webContents.send(
IPC_CHANNELS.TASK_STATUS_CHANGE, IPC_CHANNELS.TASK_STATUS_CHANGE,
taskId, taskId,
'human_review' as TaskStatus 'human_review' as TaskStatus,
projectId
); );
} }
} else { } else {
notificationService.notifyTaskFailed(taskTitle, project.id, taskId); notificationService.notifyTaskFailed(taskTitle, project.id, taskId);
persistStatus('human_review'); persistStatus('human_review');
// Include projectId for multi-project filtering (issue #723)
mainWindow.webContents.send( mainWindow.webContents.send(
IPC_CHANNELS.TASK_STATUS_CHANGE, IPC_CHANNELS.TASK_STATUS_CHANGE,
taskId, 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) => { agentManager.on('execution-progress', (taskId: string, progress: ExecutionProgressData) => {
const mainWindow = getMainWindow(); const mainWindow = getMainWindow();
if (mainWindow) { 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> = { const phaseToStatus: Record<string, TaskStatus | null> = {
'idle': null, 'idle': null,
@@ -188,10 +206,12 @@ export function registerAgenteventsHandlers(
const newStatus = phaseToStatus[progress.phase]; const newStatus = phaseToStatus[progress.phase];
if (newStatus) { if (newStatus) {
// Include projectId in status change event for multi-project filtering
mainWindow.webContents.send( mainWindow.webContents.send(
IPC_CHANNELS.TASK_STATUS_CHANGE, IPC_CHANNELS.TASK_STATUS_CHANGE,
taskId, taskId,
newStatus newStatus,
taskProjectId
); );
// CRITICAL: Persist status to plan file(s) to prevent flip-flop on task list refresh // 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. // Uses shared utility with locking to prevent race conditions.
// IMPORTANT: We persist to BOTH main project AND worktree (if exists) to ensure // IMPORTANT: We persist to BOTH main project AND worktree (if exists) to ensure
// consistency, since getTasks() prefers the worktree version. // consistency, since getTasks() prefers the worktree version.
try { if (task && project) {
const projects = projectStore.getProjects(); try {
for (const p of projects) { // Persist to main project plan file
const tasks = projectStore.getTasks(p.id); const mainPlanPath = getPlanPath(project, task);
const task = tasks.find((t) => t.id === taskId || t.specId === taskId); persistPlanStatusSync(mainPlanPath, newStatus, project.id);
if (task) {
// Persist to main project plan file
const mainPlanPath = getPlanPath(p, task);
persistPlanStatusSync(mainPlanPath, newStatus, p.id);
// Also persist to worktree plan file if it exists // Also persist to worktree plan file if it exists
// This ensures consistency since getTasks() prefers worktree version // This ensures consistency since getTasks() prefers worktree version
const worktreePath = findTaskWorktree(p.path, task.specId); const worktreePath = findTaskWorktree(project.path, task.specId);
if (worktreePath) { if (worktreePath) {
const specsBaseDir = getSpecsDir(p.autoBuildPath); const specsBaseDir = getSpecsDir(project.autoBuildPath);
const worktreePlanPath = path.join( const worktreePlanPath = path.join(
worktreePath, worktreePath,
specsBaseDir, specsBaseDir,
task.specId, task.specId,
AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN
); );
if (existsSync(worktreePlanPath)) { if (existsSync(worktreePlanPath)) {
persistPlanStatusSync(worktreePlanPath, newStatus, p.id); 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) => { fileWatcher.on('progress', (taskId: string, plan: ImplementationPlan) => {
const mainWindow = getMainWindow(); const mainWindow = getMainWindow();
if (mainWindow) { 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) => { fileWatcher.on('error', (taskId: string, error: string) => {
const mainWindow = getMainWindow(); const mainWindow = getMainWindow();
if (mainWindow) { 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);
} }
}); });
} }
+26 -20
View File
@@ -59,12 +59,13 @@ export interface TaskAPI {
unarchiveTasks: (projectId: string, taskIds: string[]) => Promise<IPCResult<boolean>>; unarchiveTasks: (projectId: string, taskIds: string[]) => Promise<IPCResult<boolean>>;
// Task Event Listeners // Task Event Listeners
onTaskProgress: (callback: (taskId: string, plan: ImplementationPlan) => void) => () => void; // Note: projectId is optional for backward compatibility - events without projectId will still work
onTaskError: (callback: (taskId: string, error: string) => void) => () => void; onTaskProgress: (callback: (taskId: string, plan: ImplementationPlan, projectId?: string) => void) => () => void;
onTaskLog: (callback: (taskId: string, log: string) => void) => () => void; onTaskError: (callback: (taskId: string, error: string, projectId?: string) => void) => () => void;
onTaskStatusChange: (callback: (taskId: string, status: TaskStatus) => void) => () => void; onTaskLog: (callback: (taskId: string, log: string, projectId?: string) => void) => () => void;
onTaskStatusChange: (callback: (taskId: string, status: TaskStatus, projectId?: string) => void) => () => void;
onTaskExecutionProgress: ( onTaskExecutionProgress: (
callback: (taskId: string, progress: import('../../shared/types').ExecutionProgress) => void callback: (taskId: string, progress: import('../../shared/types').ExecutionProgress, projectId?: string) => void
) => () => void; ) => () => void;
// Task Phase Logs // Task Phase Logs
@@ -161,14 +162,15 @@ export const createTaskAPI = (): TaskAPI => ({
// Task Event Listeners // Task Event Listeners
onTaskProgress: ( onTaskProgress: (
callback: (taskId: string, plan: ImplementationPlan) => void callback: (taskId: string, plan: ImplementationPlan, projectId?: string) => void
): (() => void) => { ): (() => void) => {
const handler = ( const handler = (
_event: Electron.IpcRendererEvent, _event: Electron.IpcRendererEvent,
taskId: string, taskId: string,
plan: ImplementationPlan plan: ImplementationPlan,
projectId?: string
): void => { ): void => {
callback(taskId, plan); callback(taskId, plan, projectId);
}; };
ipcRenderer.on(IPC_CHANNELS.TASK_PROGRESS, handler); ipcRenderer.on(IPC_CHANNELS.TASK_PROGRESS, handler);
return () => { return () => {
@@ -177,14 +179,15 @@ export const createTaskAPI = (): TaskAPI => ({
}, },
onTaskError: ( onTaskError: (
callback: (taskId: string, error: string) => void callback: (taskId: string, error: string, projectId?: string) => void
): (() => void) => { ): (() => void) => {
const handler = ( const handler = (
_event: Electron.IpcRendererEvent, _event: Electron.IpcRendererEvent,
taskId: string, taskId: string,
error: string error: string,
projectId?: string
): void => { ): void => {
callback(taskId, error); callback(taskId, error, projectId);
}; };
ipcRenderer.on(IPC_CHANNELS.TASK_ERROR, handler); ipcRenderer.on(IPC_CHANNELS.TASK_ERROR, handler);
return () => { return () => {
@@ -193,14 +196,15 @@ export const createTaskAPI = (): TaskAPI => ({
}, },
onTaskLog: ( onTaskLog: (
callback: (taskId: string, log: string) => void callback: (taskId: string, log: string, projectId?: string) => void
): (() => void) => { ): (() => void) => {
const handler = ( const handler = (
_event: Electron.IpcRendererEvent, _event: Electron.IpcRendererEvent,
taskId: string, taskId: string,
log: string log: string,
projectId?: string
): void => { ): void => {
callback(taskId, log); callback(taskId, log, projectId);
}; };
ipcRenderer.on(IPC_CHANNELS.TASK_LOG, handler); ipcRenderer.on(IPC_CHANNELS.TASK_LOG, handler);
return () => { return () => {
@@ -209,14 +213,15 @@ export const createTaskAPI = (): TaskAPI => ({
}, },
onTaskStatusChange: ( onTaskStatusChange: (
callback: (taskId: string, status: TaskStatus) => void callback: (taskId: string, status: TaskStatus, projectId?: string) => void
): (() => void) => { ): (() => void) => {
const handler = ( const handler = (
_event: Electron.IpcRendererEvent, _event: Electron.IpcRendererEvent,
taskId: string, taskId: string,
status: TaskStatus status: TaskStatus,
projectId?: string
): void => { ): void => {
callback(taskId, status); callback(taskId, status, projectId);
}; };
ipcRenderer.on(IPC_CHANNELS.TASK_STATUS_CHANGE, handler); ipcRenderer.on(IPC_CHANNELS.TASK_STATUS_CHANGE, handler);
return () => { return () => {
@@ -225,14 +230,15 @@ export const createTaskAPI = (): TaskAPI => ({
}, },
onTaskExecutionProgress: ( onTaskExecutionProgress: (
callback: (taskId: string, progress: import('../../shared/types').ExecutionProgress) => void callback: (taskId: string, progress: import('../../shared/types').ExecutionProgress, projectId?: string) => void
): (() => void) => { ): (() => void) => {
const handler = ( const handler = (
_event: Electron.IpcRendererEvent, _event: Electron.IpcRendererEvent,
taskId: string, taskId: string,
progress: import('../../shared/types').ExecutionProgress progress: import('../../shared/types').ExecutionProgress,
projectId?: string
): void => { ): void => {
callback(taskId, progress); callback(taskId, progress, projectId);
}; };
ipcRenderer.on(IPC_CHANNELS.TASK_EXECUTION_PROGRESS, handler); ipcRenderer.on(IPC_CHANNELS.TASK_EXECUTION_PROGRESS, handler);
return () => { return () => {
+33 -5
View File
@@ -3,6 +3,7 @@ import { unstable_batchedUpdates } from 'react-dom';
import { useTaskStore } from '../stores/task-store'; import { useTaskStore } from '../stores/task-store';
import { useRoadmapStore } from '../stores/roadmap-store'; import { useRoadmapStore } from '../stores/roadmap-store';
import { useRateLimitStore } from '../stores/rate-limit-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'; 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 * Hook to set up IPC event listeners for task updates
*/ */
@@ -129,13 +145,17 @@ export function useIpcListeners(): void {
useEffect(() => { useEffect(() => {
// Set up listeners with batched updates // Set up listeners with batched updates
const cleanupProgress = window.electronAPI.onTaskProgress( 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 }); queueUpdate(taskId, { plan });
} }
); );
const cleanupError = window.electronAPI.onTaskError( 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 // Errors are not batched - show immediately
setError(`Task ${taskId}: ${error}`); setError(`Task ${taskId}: ${error}`);
appendLog(taskId, `[ERROR] ${error}`); appendLog(taskId, `[ERROR] ${error}`);
@@ -143,20 +163,28 @@ export function useIpcListeners(): void {
); );
const cleanupLog = window.electronAPI.onTaskLog( 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) // Logs are now batched to reduce state updates (was causing 100+ updates/sec)
queueUpdate(taskId, { logs: [log] }); queueUpdate(taskId, { logs: [log] });
} }
); );
const cleanupStatus = window.electronAPI.onTaskStatusChange( 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 }); queueUpdate(taskId, { status });
} }
); );
const cleanupExecutionProgress = window.electronAPI.onTaskExecutionProgress( 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 }); queueUpdate(taskId, { progress });
} }
); );