feat(FN-1152): add retry flows for failed AI planning sessions

- Keep errored AI sessions retryable in the session store and add backend retry handlers for planning, subtask breakdown, and mission interview flows
- Add retry API routes and dashboard API client helpers for planning, subtask, and mission interview session retries
- Update PlanningModeModal, SubtaskBreakdownModal, MissionInterviewModal, and background session handling to show error states with retry/cancel UX
- Expand unit and integration tests across store, services, routes, and modal components to cover retry success and failure paths
This commit is contained in:
gsxdsm
2026-04-08 15:10:46 -07:00
parent 5f53949e2a
commit e80fb448d4
20 changed files with 1368 additions and 234 deletions

View File

@@ -3,6 +3,7 @@ import type { Task, PlanningQuestion, PlanningSummary } from "@fusion/core";
import {
startPlanningStreaming,
respondToPlanning,
retryPlanningSession,
createTaskFromPlanning,
connectPlanningStream,
fetchAiSession,
@@ -20,7 +21,7 @@ import {
getPlanningDescription,
clearPlanningDescription,
} from "../hooks/modalPersistence";
import { Lightbulb, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles, ListTree, GripVertical, ArrowUp, ArrowDown, Plus, Trash2, Minimize2 } from "lucide-react";
import { Lightbulb, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles, ListTree, GripVertical, ArrowUp, ArrowDown, Plus, Trash2, Minimize2, RefreshCw } from "lucide-react";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { ConversationHistory } from "./ConversationHistory";
@@ -44,6 +45,7 @@ type ViewState =
| { type: "initial" }
| { type: "question"; session: PlanningSession }
| { type: "summary"; session: PlanningSession; summary: PlanningSummary }
| { type: "error"; session: PlanningSession; errorMessage: string }
| { type: "breakdown"; sessionId: string; subtasks: SubtaskItem[]; dirty: boolean }
| { type: "loading" }
| { type: "creating" };
@@ -90,6 +92,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const [streamingOutput, setStreamingOutput] = useState<string>("");
const [showThinking, setShowThinking] = useState(true);
const [isReconnecting, setIsReconnecting] = useState(false);
const [isRetrying, setIsRetrying] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const streamConnectionRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null);
const currentSessionIdRef = useRef<string | null>(null);
@@ -140,6 +143,68 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
);
}, []);
const connectToPlanningStream = useCallback(
(sessionId: string) => {
streamConnectionRef.current?.close();
const connection = connectPlanningStream(sessionId, projectId, {
onThinking: (data) => {
setStreamingOutput((prev) => prev + data);
},
onQuestion: (question) => {
setIsReconnecting(false);
setIsRetrying(false);
clearPlanningDescription(projectId);
setView({
type: "question",
session: { sessionId, currentQuestion: question, summary: null },
});
setStreamingOutput("");
},
onSummary: (summary) => {
setIsReconnecting(false);
setIsRetrying(false);
clearPlanningDescription(projectId);
setView({
type: "summary",
session: { sessionId, currentQuestion: null, summary },
summary,
});
setEditedSummary(summary);
setStreamingOutput("");
},
onError: (message) => {
const errorMessage = message || "Session failed while contacting the AI.";
setIsReconnecting(false);
setIsRetrying(false);
setError(null);
setView((prev) => {
if (prev.type === "question" || prev.type === "summary" || prev.type === "error") {
return { type: "error", session: prev.session, errorMessage };
}
return {
type: "error",
session: { sessionId, currentQuestion: null, summary: null },
errorMessage,
};
});
setStreamingOutput("");
currentSessionIdRef.current = sessionId;
},
onComplete: () => {
setIsReconnecting(false);
setIsRetrying(false);
currentSessionIdRef.current = null;
},
onConnectionStateChange: (state) => {
setIsReconnecting(state === "reconnecting");
},
});
streamConnectionRef.current = connection;
},
[projectId],
);
const handleStartPlanning = useCallback(async (planOverride?: string) => {
const plan = planOverride ?? initialPlan;
if (!plan.trim()) return;
@@ -161,48 +226,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const { sessionId } = await startPlanningStreaming(plan.trim(), projectId, modelOverride);
currentSessionIdRef.current = sessionId;
// Connect to SSE stream
const connection = connectPlanningStream(sessionId, projectId, {
onThinking: (data) => {
setStreamingOutput((prev) => prev + data);
},
onQuestion: (question) => {
setIsReconnecting(false);
clearPlanningDescription(projectId);
setView({
type: "question",
session: { sessionId, currentQuestion: question, summary: null },
});
setStreamingOutput("");
},
onSummary: (summary) => {
setIsReconnecting(false);
clearPlanningDescription(projectId);
setView({
type: "summary",
session: { sessionId, currentQuestion: null, summary },
summary,
});
setEditedSummary(summary);
setStreamingOutput("");
},
onError: (message) => {
setIsReconnecting(false);
setError(message);
setView({ type: "initial" });
setStreamingOutput("");
currentSessionIdRef.current = null;
},
onComplete: () => {
setIsReconnecting(false);
currentSessionIdRef.current = null;
},
onConnectionStateChange: (state) => {
setIsReconnecting(state === "reconnecting");
},
});
streamConnectionRef.current = connection;
connectToPlanningStream(sessionId);
setResponseHistory([]);
} catch (err: any) {
setIsReconnecting(false);
@@ -210,7 +234,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setView({ type: "initial" });
currentSessionIdRef.current = null;
}
}, [initialPlan, planningModelId, planningModelProvider, projectId]);
}, [connectToPlanningStream, initialPlan, planningModelId, planningModelProvider, projectId]);
// Focus textarea when opening
useEffect(() => {
@@ -279,52 +303,28 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
} else if (session.status === "generating") {
setView({ type: "loading" });
if (session.thinkingOutput) setStreamingOutput(session.thinkingOutput);
// Connect to live SSE stream to pick up new events
const connection = connectPlanningStream(resumeSessionId, projectId, {
onThinking: (data) => setStreamingOutput((prev) => prev + data),
onQuestion: (question) => {
setIsReconnecting(false);
clearPlanningDescription(projectId);
setView({ type: "question", session: { sessionId: resumeSessionId, currentQuestion: question, summary: null } });
setStreamingOutput("");
},
onSummary: (summary) => {
setIsReconnecting(false);
clearPlanningDescription(projectId);
setView({ type: "summary", session: { sessionId: resumeSessionId, currentQuestion: null, summary }, summary });
setEditedSummary(summary);
setStreamingOutput("");
},
onError: (message) => {
setIsReconnecting(false);
setError(message);
setView({ type: "initial" });
},
onComplete: () => {
setIsReconnecting(false);
currentSessionIdRef.current = null;
},
onConnectionStateChange: (state) => {
setIsReconnecting(state === "reconnecting");
},
});
streamConnectionRef.current = connection;
connectToPlanningStream(resumeSessionId);
} else if (session.status === "error") {
setError(session.error || "Session failed");
setView({ type: "initial" });
setError(null);
setView({
type: "error",
session: { sessionId: resumeSessionId, currentQuestion: null, summary: null },
errorMessage: session.error || "Session failed",
});
}
} catch {
setError("Failed to resume session");
}
})();
return () => { cancelled = true; };
}, [isOpen, resumeSessionId, view.type, projectId]);
}, [connectToPlanningStream, isOpen, resumeSessionId, view.type, projectId]);
// Reset hasAutoStarted when modal closes
useEffect(() => {
if (!isOpen) {
hasAutoStartedRef.current = false;
setIsReconnecting(false);
setIsRetrying(false);
}
}, [isOpen]);
@@ -374,6 +374,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setEditedSummary(null);
setStreamingOutput("");
setIsReconnecting(false);
setIsRetrying(false);
setPlanningModelProvider(undefined);
setPlanningModelId(undefined);
currentSessionIdRef.current = null;
@@ -437,6 +438,36 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
[projectId, view]
);
const handleRetryFromError = useCallback(async () => {
if (view.type !== "error") {
return;
}
const retryTarget = view.session;
setError(null);
setIsRetrying(true);
setStreamingOutput("");
setView({ type: "loading" });
connectToPlanningStream(retryTarget.sessionId);
try {
currentSessionIdRef.current = retryTarget.sessionId;
await retryPlanningSession(retryTarget.sessionId, projectId);
} catch (err: any) {
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
setView({
type: "error",
session: retryTarget,
errorMessage: err?.message || "Retry failed. Please try again.",
});
setIsReconnecting(false);
} finally {
setIsRetrying(false);
}
}, [connectToPlanningStream, projectId, view]);
const handleCreateTask = useCallback(async () => {
if (view.type !== "summary") return;
@@ -519,7 +550,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
};
const showSendToBackgroundButton =
view.type === "loading" || view.type === "question" || view.type === "summary";
view.type === "loading" || view.type === "question" || view.type === "summary" || view.type === "error";
if (!isOpen) return null;
@@ -701,6 +732,42 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
</div>
)}
{view.type === "error" && (
<div className="planning-summary">
<div className="planning-view-scroll planning-summary-scroll">
{conversationHistory.length > 0 && (
<>
<ConversationHistory entries={conversationHistory} />
<div className="conversation-separator" />
</>
)}
<div
className="ai-error-panel"
role="alert"
style={{
border: "1px solid var(--color-error, #dc2626)",
borderRadius: "10px",
background: "color-mix(in srgb, var(--color-error, #dc2626) 10%, transparent)",
padding: "14px",
display: "grid",
gap: "10px",
}}
>
<div className="ai-error-icon" style={{ fontSize: "20px" }}></div>
<div className="ai-error-message">{view.errorMessage}</div>
<div className="ai-error-actions" style={{ display: "flex", gap: "8px" }}>
<button className="btn btn-primary" onClick={() => void handleRetryFromError()} disabled={isRetrying}>
{isRetrying ? <Loader2 size={14} className="spin" /> : <RefreshCw size={14} />}
<span style={{ marginLeft: "6px" }}>{isRetrying ? "Retrying..." : "Retry"}</span>
</button>
<button className="btn" onClick={handleCancel} disabled={isRetrying}>Dismiss</button>
</div>
</div>
</div>
</div>
)}
{view.type === "creating" && (
<div className="planning-loading">
<Loader2 size={40} className="spin" style={{ color: "var(--todo)" }} />