feat(issues): rewrite investigation IPC handlers for full lifecycle (F3)

Replace the old single-handler investigation flow with the complete
7-channel IPC handler system. Adds start/cancel investigation with
subprocess management, create-task from report, dismiss, post to GitHub,
and get/save investigation settings. Legacy handler retained for
backwards compatibility.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Sondre Engebråten
2026-02-13 18:59:35 +01:00
co-authored by Claude Opus 4.6
parent cda24a3e71
commit 899ebf6848
@@ -1,21 +1,104 @@
/**
* GitHub issue investigation IPC handlers
*
* Handles the full investigation lifecycle:
* - Start investigation (spawn Python orchestrator subprocess)
* - Cancel investigation (kill running subprocess)
* - Create task from investigation report
* - Dismiss issue
* - Post investigation results to GitHub
* - Get/save investigation settings
*
* Also retains the legacy GITHUB_INVESTIGATE_ISSUE handler for backwards
* compatibility with the old one-shot investigation flow.
*/
import { ipcMain } from 'electron';
import type { BrowserWindow } from 'electron';
import { IPC_CHANNELS } from '../../../shared/constants';
import type { GitHubInvestigationResult, GitHubInvestigationStatus } from '../../../shared/types';
import type { ChildProcess } from 'child_process';
import fs from 'fs';
import path from 'path';
import { IPC_CHANNELS, MODEL_ID_MAP, DEFAULT_FEATURE_MODELS, DEFAULT_FEATURE_THINKING } from '../../../shared/constants';
import type {
GitHubInvestigationResult,
GitHubInvestigationStatus,
InvestigationProgress,
InvestigationResult,
InvestigationSettings,
InvestigationDismissReason,
} from '../../../shared/types';
import type { AuthFailureInfo } from '../../../shared/types/terminal';
import type { AppSettings } from '../../../shared/types';
import { projectStore } from '../../project-store';
import { writeJsonWithRetry } from '../../utils/atomic-file';
import { readSettingsFile } from '../../settings-utils';
import { AgentManager } from '../../agent';
import { getGitHubConfig, githubFetch } from './utils';
import type { GitHubAPIComment } from './types';
import { createSpecForIssue, buildIssueContext, buildInvestigationTask } from './spec-utils';
import { createContextLogger } from './utils/logger';
import { withProjectOrNull } from './utils/project-middleware';
import { createIPCCommunicators } from './utils/ipc-communicator';
import { getRunnerEnv } from './utils/runner-env';
import {
runPythonSubprocess,
getPythonPath,
getRunnerPath,
validateGitHubModule,
buildRunnerArgs,
parseJSONFromOutput,
} from './utils/subprocess-runner';
import { killProcessGracefully } from '../../platform';
const { debug: debugLog } = createContextLogger('Investigation');
// Track active investigation subprocesses, keyed by `${projectId}:${issueNumber}`
const activeInvestigations = new Map<string, ChildProcess>();
/**
* Send investigation progress update to renderer
* Get GitHub Issues model and thinking settings from app settings
*/
function sendProgress(
function getGitHubIssuesSettings(): { model: string; thinkingLevel: string } {
const rawSettings = readSettingsFile() as Partial<AppSettings> | undefined;
const featureModels = rawSettings?.featureModels ?? DEFAULT_FEATURE_MODELS;
const featureThinking = rawSettings?.featureThinking ?? DEFAULT_FEATURE_THINKING;
const modelShort = featureModels.githubIssues ?? DEFAULT_FEATURE_MODELS.githubIssues;
const thinkingLevel = featureThinking.githubIssues ?? DEFAULT_FEATURE_THINKING.githubIssues;
const model = MODEL_ID_MAP[modelShort] ?? MODEL_ID_MAP['opus'];
return { model, thinkingLevel };
}
/**
* Get the GitHub config directory for a project
*/
function getGitHubDir(projectPath: string): string {
return path.join(projectPath, '.auto-claude', 'github');
}
/**
* Default investigation settings
*/
function createDefaultSettings(): InvestigationSettings {
return {
autoCreateTasks: false,
autoStartTasks: false,
pipelineMode: 'full',
autoPostToGitHub: false,
autoCloseIssues: false,
maxParallelInvestigations: 3,
labelIncludeFilter: [],
labelExcludeFilter: [],
};
}
// ============================================
// Legacy handler (old one-shot investigation)
// ============================================
/**
* Send investigation progress update to renderer (legacy)
*/
function sendLegacyProgress(
mainWindow: BrowserWindow,
projectId: string,
status: GitHubInvestigationStatus
@@ -28,9 +111,9 @@ function sendProgress(
}
/**
* Send investigation error to renderer
* Send investigation error to renderer (legacy)
*/
function sendError(
function sendLegacyError(
mainWindow: BrowserWindow,
projectId: string,
error: string
@@ -43,9 +126,9 @@ function sendError(
}
/**
* Send investigation completion to renderer
* Send investigation completion to renderer (legacy)
*/
function sendComplete(
function sendLegacyComplete(
mainWindow: BrowserWindow,
projectId: string,
result: GitHubInvestigationResult
@@ -58,9 +141,9 @@ function sendComplete(
}
/**
* Investigate a GitHub issue and create a task
* Legacy: Investigate a GitHub issue and create a task (old one-shot flow)
*/
export function registerInvestigateIssue(
function registerLegacyInvestigateIssue(
_agentManager: AgentManager,
getMainWindow: () => BrowserWindow | null
): void {
@@ -72,26 +155,24 @@ export function registerInvestigateIssue(
const project = projectStore.getProject(projectId);
if (!project) {
sendError(mainWindow, projectId, 'Project not found');
sendLegacyError(mainWindow, projectId, 'Project not found');
return;
}
const config = getGitHubConfig(project);
if (!config) {
sendError(mainWindow, projectId, 'No GitHub token or repository configured');
sendLegacyError(mainWindow, projectId, 'No GitHub token or repository configured');
return;
}
try {
// Phase 1: Fetching issue details
sendProgress(mainWindow, projectId, {
sendLegacyProgress(mainWindow, projectId, {
phase: 'fetching',
issueNumber,
progress: 10,
message: 'Fetching issue details...'
});
// Fetch the issue
const issue = await githubFetch(
config.token,
`/repos/${config.repo}/issues/${issueNumber}`
@@ -103,19 +184,15 @@ export function registerInvestigateIssue(
html_url: string;
};
// Fetch issue comments for more context
const allComments = await githubFetch(
config.token,
`/repos/${config.repo}/issues/${issueNumber}/comments`
) as GitHubAPIComment[];
// Filter comments based on selection (if provided)
// Use Array.isArray to handle empty array case (all comments deselected)
const comments = Array.isArray(selectedCommentIds)
? allComments.filter(c => selectedCommentIds.includes(c.id))
: allComments;
// Build context for the AI investigation
const labels = issue.labels.map(l => l.name);
const issueContext = buildIssueContext(
issue.number,
@@ -126,22 +203,19 @@ export function registerInvestigateIssue(
comments
);
// Phase 2: Analyzing issue
sendProgress(mainWindow, projectId, {
sendLegacyProgress(mainWindow, projectId, {
phase: 'analyzing',
issueNumber,
progress: 30,
message: 'AI is analyzing the issue...'
});
// Build task description
const taskDescription = buildInvestigationTask(
issue.number,
issue.title,
issueContext
);
// Create spec directory and files (with coordinated numbering)
const specData = await createSpecForIssue(
project,
issue.number,
@@ -149,22 +223,16 @@ export function registerInvestigateIssue(
taskDescription,
issue.html_url,
labels,
project.settings?.mainBranch // Pass project's configured main branch
project.settings?.mainBranch
);
// NOTE: We intentionally do NOT call agentManager.startSpecCreation() here
// This allows the task to stay in "backlog" status until the user manually starts it
// Previously, calling startSpecCreation would auto-start the task immediately
// Phase 3: Creating task
sendProgress(mainWindow, projectId, {
sendLegacyProgress(mainWindow, projectId, {
phase: 'creating_task',
issueNumber,
progress: 70,
message: 'Creating task from investigation...'
});
// Build investigation result
const investigationResult: GitHubInvestigationResult = {
success: true,
issueNumber,
@@ -182,18 +250,17 @@ export function registerInvestigateIssue(
taskId: specData.specId
};
// Phase 4: Complete
sendProgress(mainWindow, projectId, {
sendLegacyProgress(mainWindow, projectId, {
phase: 'complete',
issueNumber,
progress: 100,
message: 'Investigation complete!'
});
sendComplete(mainWindow, projectId, investigationResult);
sendLegacyComplete(mainWindow, projectId, investigationResult);
} catch (error) {
sendError(
sendLegacyError(
mainWindow,
projectId,
error instanceof Error ? error.message : 'Failed to investigate issue'
@@ -203,6 +270,10 @@ export function registerInvestigateIssue(
);
}
// ============================================
// New investigation system handlers
// ============================================
/**
* Register all investigation-related handlers
*/
@@ -210,5 +281,402 @@ export function registerInvestigationHandlers(
agentManager: AgentManager,
getMainWindow: () => BrowserWindow | null
): void {
registerInvestigateIssue(agentManager, getMainWindow);
debugLog('Registering Investigation handlers');
// Keep legacy handler for backwards compatibility
registerLegacyInvestigateIssue(agentManager, getMainWindow);
// ============================================
// 1. Start investigation
// ============================================
ipcMain.on(
IPC_CHANNELS.GITHUB_INVESTIGATION_START,
async (_, projectId: string, issueNumber: number) => {
debugLog('startInvestigation handler called', { projectId, issueNumber });
const mainWindow = getMainWindow();
if (!mainWindow) return;
const { sendProgress, sendError, sendComplete } = createIPCCommunicators<
InvestigationProgress,
InvestigationResult
>(
mainWindow,
{
progress: IPC_CHANNELS.GITHUB_INVESTIGATION_PROGRESS,
error: IPC_CHANNELS.GITHUB_INVESTIGATION_ERROR,
complete: IPC_CHANNELS.GITHUB_INVESTIGATION_COMPLETE,
},
projectId,
);
if (!Number.isInteger(issueNumber) || issueNumber <= 0) {
sendError('Invalid issue number');
return;
}
const processKey = `${projectId}:${issueNumber}`;
// Cancel any existing investigation for this issue
const existingProcess = activeInvestigations.get(processKey);
if (existingProcess && !existingProcess.killed) {
killProcessGracefully(existingProcess);
activeInvestigations.delete(processKey);
}
try {
await withProjectOrNull(projectId, async (project) => {
const validation = await validateGitHubModule(project);
if (!validation.valid) {
sendError(validation.error ?? 'GitHub module not available');
return;
}
const backendPath = validation.backendPath ?? '';
const { model, thinkingLevel } = getGitHubIssuesSettings();
const args = buildRunnerArgs(
getRunnerPath(backendPath),
project.path,
'investigate',
[String(issueNumber)],
{ model, thinkingLevel },
);
const startedAt = new Date().toISOString();
sendProgress({
issueNumber,
phase: 'starting',
progress: 5,
message: 'Starting investigation...',
agentStatuses: [
{ agentType: 'root_cause', status: 'pending', progress: 0 },
{ agentType: 'impact', status: 'pending', progress: 0 },
{ agentType: 'fix_advisor', status: 'pending', progress: 0 },
{ agentType: 'reproducer', status: 'pending', progress: 0 },
],
startedAt,
});
const subprocessEnv = await getRunnerEnv();
const { process: childProcess, promise } = runPythonSubprocess<InvestigationResult>({
pythonPath: getPythonPath(backendPath),
args,
cwd: backendPath,
env: subprocessEnv,
onProgress: (percent, message, data) => {
// Parse agent status updates from progress data if available
const progressUpdate: InvestigationProgress = {
issueNumber,
phase: percent < 90 ? 'investigating' : 'finalizing',
progress: percent,
message,
agentStatuses: (data as InvestigationProgress | undefined)?.agentStatuses ?? [],
startedAt,
};
sendProgress(progressUpdate);
},
onComplete: (stdout) => parseJSONFromOutput<InvestigationResult>(stdout),
onStdout: (line) => debugLog('STDOUT:', line),
onStderr: (line) => debugLog('STDERR:', line),
onAuthFailure: (authFailureInfo: AuthFailureInfo) => {
mainWindow.webContents.send(IPC_CHANNELS.CLAUDE_AUTH_FAILURE, authFailureInfo);
},
});
activeInvestigations.set(processKey, childProcess);
let result;
try {
result = await promise;
} finally {
activeInvestigations.delete(processKey);
}
if (!result.success) {
sendError(result.error ?? 'Investigation failed');
return;
}
const investigationResult = result.data as InvestigationResult;
sendComplete(investigationResult);
});
} catch (error) {
sendError(error instanceof Error ? error.message : 'Failed to start investigation');
}
},
);
// ============================================
// 2. Cancel investigation
// ============================================
ipcMain.on(
IPC_CHANNELS.GITHUB_INVESTIGATION_CANCEL,
(_, projectId: string, issueNumber: number) => {
debugLog('cancelInvestigation handler called', { projectId, issueNumber });
const processKey = `${projectId}:${issueNumber}`;
const proc = activeInvestigations.get(processKey);
if (proc && !proc.killed) {
killProcessGracefully(proc);
debugLog('Investigation process killed', { processKey });
}
activeInvestigations.delete(processKey);
},
);
// ============================================
// 3. Create task from investigation
// ============================================
ipcMain.handle(
IPC_CHANNELS.GITHUB_INVESTIGATION_CREATE_TASK,
async (_, projectId: string, issueNumber: number) => {
debugLog('createTaskFromInvestigation handler called', { projectId, issueNumber });
try {
const result = await withProjectOrNull(projectId, async (project) => {
// Read investigation report from persistence
const reportPath = path.join(
getGitHubDir(project.path),
'investigations',
`${issueNumber}`,
'report.json',
);
if (!fs.existsSync(reportPath)) {
return { success: false, error: 'Investigation report not found. Run investigation first.' };
}
const reportData = JSON.parse(fs.readFileSync(reportPath, 'utf-8'));
// Build task description from investigation report
const summary = reportData.summary || `Investigation of issue #${issueNumber}`;
const fixAdvice = reportData.fixAdvice;
const taskDescription = [
`# GitHub Issue #${issueNumber}`,
'',
`## Summary`,
summary,
'',
fixAdvice?.suggestedApproaches?.length
? [
'## Suggested Approach',
fixAdvice.suggestedApproaches[fixAdvice.recommendedApproach || 0]?.description || '',
'',
'### Files to Modify',
...(fixAdvice.suggestedApproaches[fixAdvice.recommendedApproach || 0]?.filesToModify?.map(
(f: string) => `- ${f}`,
) || []),
].join('\n')
: '',
].join('\n');
const config = getGitHubConfig(project);
const githubUrl = config
? `https://github.com/${config.repo}/issues/${issueNumber}`
: '';
const labels = reportData.suggestedLabels
?.filter((l: { accepted?: boolean }) => l.accepted !== false)
.map((l: { name: string }) => l.name) ?? [];
const specData = await createSpecForIssue(
project,
issueNumber,
summary,
taskDescription,
githubUrl,
labels,
project.settings?.mainBranch,
);
return { success: true, data: { specId: specData.specId } };
});
return result ?? { success: false, error: 'Project not found' };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to create task',
};
}
},
);
// ============================================
// 4. Dismiss issue
// ============================================
ipcMain.handle(
IPC_CHANNELS.GITHUB_INVESTIGATION_DISMISS,
async (_, projectId: string, issueNumber: number, reason: InvestigationDismissReason) => {
debugLog('dismissIssue handler called', { projectId, issueNumber, reason });
try {
const result = await withProjectOrNull(projectId, async (project) => {
const githubDir = getGitHubDir(project.path);
const dismissDir = path.join(githubDir, 'investigations', 'dismissed');
fs.mkdirSync(dismissDir, { recursive: true });
const dismissPath = path.join(dismissDir, `${issueNumber}.json`);
await writeJsonWithRetry(dismissPath, {
issueNumber,
reason,
dismissedAt: new Date().toISOString(),
}, { indent: 2 });
return { success: true };
});
return result ?? { success: false, error: 'Project not found' };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to dismiss issue',
};
}
},
);
// ============================================
// 5. Post investigation results to GitHub
// ============================================
ipcMain.handle(
IPC_CHANNELS.GITHUB_INVESTIGATION_POST_GITHUB,
async (_, projectId: string, issueNumber: number) => {
debugLog('postInvestigationToGitHub handler called', { projectId, issueNumber });
try {
const result = await withProjectOrNull(projectId, async (project) => {
const validation = await validateGitHubModule(project);
if (!validation.valid) {
return { success: false, error: validation.error ?? 'GitHub module not available' };
}
const backendPath = validation.backendPath ?? '';
const { model, thinkingLevel } = getGitHubIssuesSettings();
const args = buildRunnerArgs(
getRunnerPath(backendPath),
project.path,
'post-investigation',
[String(issueNumber)],
{ model, thinkingLevel },
);
const subprocessEnv = await getRunnerEnv();
const { promise } = runPythonSubprocess<{ commentId: number }>({
pythonPath: getPythonPath(backendPath),
args,
cwd: backendPath,
env: subprocessEnv,
onComplete: (stdout) => parseJSONFromOutput<{ commentId: number }>(stdout),
onStdout: (line) => debugLog('STDOUT:', line),
onStderr: (line) => debugLog('STDERR:', line),
});
const subResult = await promise;
if (!subResult.success) {
return { success: false, error: subResult.error ?? 'Failed to post to GitHub' };
}
const postResult = subResult.data as { commentId: number };
return { success: true, data: { commentId: postResult.commentId } };
});
return result ?? { success: false, error: 'Project not found' };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to post to GitHub',
};
}
},
);
// ============================================
// 6. Get investigation settings
// ============================================
ipcMain.handle(
IPC_CHANNELS.GITHUB_INVESTIGATION_GET_SETTINGS,
async (_, projectId: string) => {
debugLog('getInvestigationSettings handler called', { projectId });
try {
const result = await withProjectOrNull(projectId, async (project) => {
const configPath = path.join(getGitHubDir(project.path), 'config.json');
try {
const data = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
if (data.investigation_settings) {
return {
success: true,
data: data.investigation_settings as InvestigationSettings,
};
}
} catch {
// File doesn't exist or is corrupted, return defaults
}
return { success: true, data: createDefaultSettings() };
});
return result ?? { success: true, data: createDefaultSettings() };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to get settings',
};
}
},
);
// ============================================
// 7. Save investigation settings
// ============================================
ipcMain.handle(
IPC_CHANNELS.GITHUB_INVESTIGATION_SAVE_SETTINGS,
async (_, projectId: string, settings: Partial<InvestigationSettings>) => {
debugLog('saveInvestigationSettings handler called', { projectId });
try {
const result = await withProjectOrNull(projectId, async (project) => {
const githubDir = getGitHubDir(project.path);
fs.mkdirSync(githubDir, { recursive: true });
const configPath = path.join(githubDir, 'config.json');
let existingConfig: Record<string, unknown> = {};
try {
existingConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
} catch {
// Use empty config if file doesn't exist
}
// Merge with existing settings (partial update)
const existingSettings = (existingConfig.investigation_settings as InvestigationSettings) ?? createDefaultSettings();
const updatedConfig = {
...existingConfig,
investigation_settings: {
...existingSettings,
...settings,
},
};
await writeJsonWithRetry(configPath, updatedConfig, { indent: 2 });
return { success: true };
});
return result ?? { success: false, error: 'Project not found' };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to save settings',
};
}
},
);
debugLog('Investigation handlers registered');
}