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
|
||||||
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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user