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:
co-authored by
Claude Opus 4.6
parent
ebfb4997fc
commit
fbe1b74cbc
@@ -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');
|
||||
|
||||
+12
-3
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user