fix(investigation): preserve sessions in state updates to enable resume button

The backend was creating fresh state dicts without preserving the `sessions`
field, causing SDK session IDs to be lost when investigations failed or
completed. This prevented the "Resume Investigation" button from appearing
after interruptions.

Changes:
- Backend: Preserve existing sessions when updating investigation state
- Backend: Load existing state before writing failed/success states
- Frontend: Pass hasResumeSessions flag through error IPC channel
- Frontend: Display "Resume Investigation" (blue) vs "Re-investigate" (orange)

The fix follows the same pattern as frontend IPC handlers: spread existing
state before adding new fields.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Sondre Engebråten
2026-02-17 11:10:38 +01:00
co-authored by Claude Opus 4.6
parent ebfb4997fc
commit fbe1b74cbc
5 changed files with 105 additions and 33 deletions
+43 -24
View File
@@ -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
@@ -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<InvestigationProgress, InvestigationResult>(
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');
@@ -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({
</Button>
)
)}
{/* Re-investigate — orange */}
{/* Re-investigate — orange, or Resume if sessions available */}
{(isComplete || isFailed) && (
<Button size="sm" variant="outline" onClick={onInvestigate}
className="border-orange-500/40 text-orange-500 hover:bg-orange-500/10"
className={hasResumeSessions
? "border-primary/40 text-primary hover:bg-primary/10"
: "border-orange-500/40 text-orange-500 hover:bg-orange-500/10"
}
>
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
{t('investigation.actions.retry', 'Re-investigate')}
{hasResumeSessions
? t('investigation.button.resume', 'Resume Investigation')
: t('investigation.actions.retry', 'Re-investigate')
}
</Button>
)}
</div>
@@ -304,6 +304,7 @@ export function IssueDetail({
isClosingIssue={isClosing}
isReopeningIssue={isReopening}
issueState={issue.state}
hasResumeSessions={investigationHasResumeSessions ?? false}
/>
{investigationReport && (
@@ -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<InvestigationStoreState>((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<InvestigationStoreState>((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<InvestigationStoreState>((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);
}