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 d0578a589b
commit 0ff6d42c0f
20 changed files with 1368 additions and 234 deletions

View File

@@ -30,6 +30,7 @@ import {
getMissionInterviewSession,
submitMissionInterviewResponse,
} from "./mission-interview.js";
import * as missionInterviewModule from "./mission-interview.js";
// Mock MissionStore factory
function createMockMissionStore() {
@@ -421,6 +422,7 @@ function createMockMissionStore() {
function createMockStore(): TaskStore {
return {
getMissionStore: vi.fn().mockReturnValue(createMockMissionStore()),
getRootDir: vi.fn().mockReturnValue("/fake/root"),
pauseTask: vi.fn(),
} as unknown as TaskStore;
}
@@ -490,7 +492,7 @@ class MockAiSessionStore {
listRecoverable(): AiSessionRow[] {
return [...this.rows.values()].filter(
(row) => row.status === "awaiting_input" || row.status === "generating",
(row) => row.status === "awaiting_input" || row.status === "generating" || row.status === "error",
);
}
@@ -1794,6 +1796,43 @@ describe("Mission API", () => {
expect(res.body.error).toContain("sessionId");
});
it("retries a failed interview session", async () => {
const retrySpy = vi
.spyOn(missionInterviewModule, "retryMissionInterviewSession")
.mockResolvedValueOnce(undefined);
const { app } = buildApp();
const res = await request(app, "POST", "/api/missions/interview/session-1/retry");
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true, sessionId: "session-1" });
expect(retrySpy).toHaveBeenCalledWith("session-1", "/fake/root");
});
it("returns 404 when interview retry session is missing", async () => {
vi.spyOn(missionInterviewModule, "retryMissionInterviewSession").mockRejectedValueOnce(
new missionInterviewModule.SessionNotFoundError("Interview session missing"),
);
const { app } = buildApp();
const res = await request(app, "POST", "/api/missions/interview/session-404/retry");
expect(res.status).toBe(404);
expect(res.body.error).toContain("Interview session missing");
});
it("returns 400 when interview retry session is not in error state", async () => {
vi.spyOn(missionInterviewModule, "retryMissionInterviewSession").mockRejectedValueOnce(
new missionInterviewModule.InvalidSessionStateError("Session is not in an error state"),
);
const { app } = buildApp();
const res = await request(app, "POST", "/api/missions/interview/session-400/retry");
expect(res.status).toBe(400);
expect(res.body.error).toContain("not in an error state");
});
it("replays buffered interview events when Last-Event-ID is provided", async () => {
const { app } = buildApp();
const sessionId = await createMissionInterviewSession("127.0.0.1", "Replay Mission", "/tmp/project");