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/
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);
@@ -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<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);
}
});
}
+26 -20
View File
@@ -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 () => {
+33 -5
View File
@@ -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 });
}
);