diff --git a/apps/backend/runners/github/orchestrator.py b/apps/backend/runners/github/orchestrator.py index 0310ae56..df9cd991 100644 --- a/apps/backend/runners/github/orchestrator.py +++ b/apps/backend/runners/github/orchestrator.py @@ -1575,16 +1575,21 @@ class GitHubOrchestrator: working_dir = project_root or self.project_dir - # Save initial state + # Save initial state, preserving any existing sessions (for resume scenarios) + existing_state = load_investigation_state(self.project_dir, issue_number) + initial_state = { + "issue_number": issue_number, + "status": "investigating", + "started_at": datetime.now(timezone.utc).isoformat(), + "model_used": self.config.model or "sonnet", + } + # Preserve existing sessions if present (resume scenario) + if existing_state and existing_state.sessions: + initial_state["sessions"] = existing_state.sessions save_investigation_state( self.project_dir, issue_number, - { - "issue_number": issue_number, - "status": "investigating", - "started_at": datetime.now(timezone.utc).isoformat(), - "model_used": self.config.model or "sonnet", - }, + initial_state, ) # Sync lifecycle label to GitHub @@ -1616,7 +1621,7 @@ class GitHubOrchestrator: resume_sessions=resume_sessions, ) - # Update state to findings_ready, preserving started_at + # Update state to findings_ready, preserving started_at and sessions existing_state = load_investigation_state(self.project_dir, issue_number) started_at = ( existing_state.started_at @@ -1624,16 +1629,21 @@ class GitHubOrchestrator: else datetime.now(timezone.utc).isoformat() ) + success_state = { + "issue_number": issue_number, + "status": "findings_ready", + "started_at": started_at, + "completed_at": datetime.now(timezone.utc).isoformat(), + "model_used": self.config.model or "sonnet", + } + # Preserve existing sessions if present (for edge case where user might want to re-run) + if existing_state and existing_state.sessions: + success_state["sessions"] = existing_state.sessions + save_investigation_state( self.project_dir, issue_number, - { - "issue_number": issue_number, - "status": "findings_ready", - "started_at": started_at, - "completed_at": datetime.now(timezone.utc).isoformat(), - "model_used": self.config.model or "sonnet", - }, + success_state, ) # Sync lifecycle label to GitHub @@ -1647,18 +1657,27 @@ class GitHubOrchestrator: return report.model_dump(mode="json") if return_dict else report except Exception as e: - # Update state to failed + # Update state to failed, preserving any existing sessions for resume support + existing_state = load_investigation_state(self.project_dir, issue_number) + failed_state = { + "issue_number": issue_number, + "status": "failed", + "started_at": ( + existing_state.started_at + if existing_state and existing_state.started_at + else datetime.now(timezone.utc).isoformat() + ), + "completed_at": datetime.now(timezone.utc).isoformat(), + "error": str(e), + "model_used": self.config.model or "sonnet", + } + # Preserve existing sessions if present (critical for resume feature) + if existing_state and existing_state.sessions: + failed_state["sessions"] = existing_state.sessions save_investigation_state( self.project_dir, issue_number, - { - "issue_number": issue_number, - "status": "failed", - "started_at": datetime.now(timezone.utc).isoformat(), - "completed_at": datetime.now(timezone.utc).isoformat(), - "error": str(e), - "model_used": self.config.model or "sonnet", - }, + failed_state, ) # Remove lifecycle labels on failure diff --git a/apps/frontend/src/main/ipc-handlers/github/investigation-handlers.ts b/apps/frontend/src/main/ipc-handlers/github/investigation-handlers.ts index 857b22f3..8149d44c 100644 --- a/apps/frontend/src/main/ipc-handlers/github/investigation-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/github/investigation-handlers.ts @@ -1420,7 +1420,18 @@ async function runInvestigation( if (!result.success) { appendActivityLogEntry(project.path, issueNumber, `Investigation failed: ${result.error ?? 'unknown error'}`); - sendError({ error: result.error ?? 'Investigation failed', issueNumber }); + // Check if there are saved sessions for resume before sending error + const stateFile = path.join(project.path, '.auto-claude', 'issues', String(issueNumber), 'investigation_state.json'); + let hasResumeSessions = false; + try { + if (fs.existsSync(stateFile)) { + const stateData = JSON.parse(fs.readFileSync(stateFile, 'utf-8')); + hasResumeSessions = stateData.sessions && typeof stateData.sessions === 'object' && Object.keys(stateData.sessions).length > 0; + } + } catch { + // Non-fatal: if we can't read the state file, assume no sessions + } + sendError({ error: result.error ?? 'Investigation failed', issueNumber, hasResumeSessions }); return; } @@ -1728,6 +1739,32 @@ export function registerInvestigationHandlers( if (proc && !proc.killed) { killProcessGracefully(proc); debugLog('Investigation process killed', { processKey }); + + // Check for saved sessions and send error response + const mainWindow = getMainWindow(); + if (mainWindow && project) { + const stateFile = path.join(project.path, '.auto-claude', 'issues', String(issueNumber), 'investigation_state.json'); + let hasResumeSessions = false; + try { + if (fs.existsSync(stateFile)) { + const stateData = JSON.parse(fs.readFileSync(stateFile, 'utf-8')); + hasResumeSessions = stateData.sessions && typeof stateData.sessions === 'object' && Object.keys(stateData.sessions).length > 0; + } + } catch { + // Non-fatal: if we can't read the state file, assume no sessions + } + + const { sendError } = createIPCCommunicators( + mainWindow, + { + progress: IPC_CHANNELS.GITHUB_INVESTIGATION_PROGRESS, + error: IPC_CHANNELS.GITHUB_INVESTIGATION_ERROR, + complete: IPC_CHANNELS.GITHUB_INVESTIGATION_COMPLETE, + }, + projectId, + ); + sendError({ error: 'Investigation cancelled', issueNumber, hasResumeSessions }); + } } if (project) appendActivityLogEntry(project.path, issueNumber, 'Investigation cancelled'); diff --git a/apps/frontend/src/renderer/components/github-issues/components/InvestigationNeedsAttention.tsx b/apps/frontend/src/renderer/components/github-issues/components/InvestigationNeedsAttention.tsx index e507ce08..df27e65d 100644 --- a/apps/frontend/src/renderer/components/github-issues/components/InvestigationNeedsAttention.tsx +++ b/apps/frontend/src/renderer/components/github-issues/components/InvestigationNeedsAttention.tsx @@ -56,6 +56,8 @@ interface InvestigationNeedsAttentionProps { isClosingIssue?: boolean; isReopeningIssue?: boolean; issueState: 'open' | 'closed'; + /** True if the investigation has saved session IDs that can be resumed */ + hasResumeSessions?: boolean; } type StepStatus = 'completed' | 'current' | 'pending' | 'failed' | 'actionable'; @@ -65,6 +67,7 @@ export function InvestigationNeedsAttention({ githubCommentId, postedAt, specId, issueNumber, projectId, onCancel, onInvestigate, onCreateTask, onPostToGitHub, isPostingToGitHub, onDismissIssue, onCloseIssue, onReopenIssue, isClosingIssue, isReopeningIssue, issueState, + hasResumeSessions, }: InvestigationNeedsAttentionProps) { const { t } = useTranslation('common'); const [isOpen, setIsOpen] = useState(true); @@ -403,13 +406,19 @@ export function InvestigationNeedsAttention({ ) )} - {/* Re-investigate — orange */} + {/* Re-investigate — orange, or Resume if sessions available */} {(isComplete || isFailed) && ( )} diff --git a/apps/frontend/src/renderer/components/github-issues/components/IssueDetail.tsx b/apps/frontend/src/renderer/components/github-issues/components/IssueDetail.tsx index 60cbd2fd..cf7e561f 100644 --- a/apps/frontend/src/renderer/components/github-issues/components/IssueDetail.tsx +++ b/apps/frontend/src/renderer/components/github-issues/components/IssueDetail.tsx @@ -304,6 +304,7 @@ export function IssueDetail({ isClosingIssue={isClosing} isReopeningIssue={isReopening} issueState={issue.state} + hasResumeSessions={investigationHasResumeSessions ?? false} /> {investigationReport && ( diff --git a/apps/frontend/src/renderer/stores/github/investigation-store.ts b/apps/frontend/src/renderer/stores/github/investigation-store.ts index 723ef655..0a0a0158 100644 --- a/apps/frontend/src/renderer/stores/github/investigation-store.ts +++ b/apps/frontend/src/renderer/stores/github/investigation-store.ts @@ -84,7 +84,7 @@ interface InvestigationStoreState { startInvestigation: (projectId: string, issueNumber: number) => void; setProgress: (projectId: string, progress: InvestigationProgress) => void; setResult: (projectId: string, result: InvestigationResult) => void; - setError: (projectId: string, issueNumber: number, error: string) => void; + setError: (projectId: string, issueNumber: number, error: string, hasResumeSessions?: boolean) => void; dismiss: (projectId: string, issueNumber: number, reason: InvestigationDismissReason) => void; clearIssueInvestigation: (projectId: string, issueNumber: number) => void; setSettings: (projectId: string, settings: InvestigationSettings) => void; @@ -152,6 +152,7 @@ export const useInvestigationStore = create((set, get) linkedTaskStatus: existing?.linkedTaskStatus ?? null, activityLog: log, isCancelled: false, // clear cancelled flag on new investigation + hasResumeSessions: false, // new investigation starts fresh (old sessions were for previous run) } } }; @@ -220,10 +221,12 @@ export const useInvestigationStore = create((set, get) }; }), - setError: (projectId: string, issueNumber: number, error: string) => set((state) => { + setError: (projectId: string, issueNumber: number, error: string, hasResumeSessions?: boolean) => set((state) => { const key = `${projectId}:${issueNumber}`; const existing = state.investigations[key]; const log = [...(existing?.activityLog ?? []), { event: 'investigation failed', timestamp: new Date().toISOString() }].slice(-50); + // Use the provided hasResumeSessions flag, or fall back to existing value, or default to false + const hasResumeSessionsFlag = hasResumeSessions ?? existing?.hasResumeSessions ?? false; return { investigations: { ...state.investigations, @@ -244,6 +247,7 @@ export const useInvestigationStore = create((set, get) linkedTaskStatus: existing?.linkedTaskStatus ?? null, activityLog: log, isCancelled: existing?.isCancelled ?? false, + hasResumeSessions: hasResumeSessionsFlag, } } }; @@ -562,13 +566,14 @@ export function initializeInvestigationListeners(): void { // Listen for investigation error events const cleanupError = window.electronAPI.github.onInvestigationError( - (projectId: string, errorPayload: string | { error: string; issueNumber?: number }) => { + (projectId: string, errorPayload: string | { error: string; issueNumber?: number; hasResumeSessions?: boolean }) => { const errorMsg = typeof errorPayload === 'string' ? errorPayload : errorPayload.error; const issueNum = typeof errorPayload === 'object' ? errorPayload.issueNumber : undefined; + const hasResumeSessions = typeof errorPayload === 'object' ? errorPayload.hasResumeSessions : undefined; if (issueNum) { // Target the specific investigation that failed - store.setError(projectId, issueNum, errorMsg); + store.setError(projectId, issueNum, errorMsg, hasResumeSessions); toast({ title: i18next.t('common:investigation.toast.investigationFailed', { issueNumber: issueNum }), variant: 'destructive', @@ -662,7 +667,8 @@ export function cancelIssueInvestigation( })); } - // Mark as not investigating immediately + // Mark as not investigating immediately, but don't set hasResumeSessions yet + // The backend will send an error response with hasResumeSessions if sessions exist store.setError(projectId, issueNumber, 'Investigation cancelled'); window.electronAPI.github.cancelInvestigation(projectId, issueNumber); }