feat(FN-1920): merge fusion/fn-1920

This commit is contained in:
gsxdsm
2026-04-16 08:53:14 -07:00
parent caa03ad752
commit 563f169f3d
5 changed files with 420 additions and 217 deletions

View File

@@ -0,0 +1,5 @@
---
"@gsxdsm/fusion": patch
---
Fix roadmap AI suggestion generation hanging by adding 120-second timeout protection to `generateMilestoneSuggestions()` and `generateFeatureSuggestions()` functions, and a 130-second route-level safety-net timeout on both suggestion endpoints. Previously, if the AI provider was slow or unresponsive, HTTP requests would hang indefinitely. The fix ensures agent sessions are properly disposed on timeout and returns a 503 Service Unavailable response with a descriptive error message.

View File

@@ -7,15 +7,25 @@ import { createRoadmapRouter } from "./roadmap-routes.js";
import { ApiError } from "./api-error.js";
import type { Roadmap, RoadmapMilestone, RoadmapFeature, RoadmapStore } from "@fusion/core";
vi.mock("./roadmap-suggestions.js", () => ({
generateMilestoneSuggestions: vi.fn().mockResolvedValue({ suggestions: [] }),
validateSuggestionInput: vi.fn(),
generateFeatureSuggestions: vi.fn().mockResolvedValue({ suggestions: [] }),
validateFeatureSuggestionInput: vi.fn(),
ValidationError: class extends Error { name = "ValidationError"; constructor(m: string) { super(m); } },
ParseError: class extends Error { name = "ParseError"; constructor(m: string) { super(m); } },
ServiceUnavailableError: class extends Error { name = "ServiceUnavailableError"; constructor(m: string) { super(m); } },
}));
// vi.mock is hoisted
vi.mock("./roadmap-suggestions.js", () => {
// Define error classes inside the factory - these will be used by the mocked module
class MockValidationError extends Error { name = "ValidationError"; constructor(m: string) { super(m); } }
class MockParseError extends Error { name = "ParseError"; constructor(m: string) { super(m); } }
class MockServiceUnavailableError extends Error { name = "ServiceUnavailableError"; constructor(m: string) { super(m); } }
return {
generateMilestoneSuggestions: vi.fn().mockResolvedValue({ suggestions: [] }),
validateSuggestionInput: vi.fn(),
generateFeatureSuggestions: vi.fn().mockResolvedValue({ suggestions: [] }),
validateFeatureSuggestionInput: vi.fn(),
ValidationError: MockValidationError,
ParseError: MockParseError,
ServiceUnavailableError: MockServiceUnavailableError,
SUGGESTION_TIMEOUT_MS: 120_000,
};
});
const mockGetOrCreateProjectStore = vi.fn();
vi.mock("./project-store-resolver.js", () => ({
@@ -564,4 +574,57 @@ describe("Roadmap Routes", () => {
expect(response.body.description).toBe("Feature desc");
});
});
describe("POST /api/roadmaps/:roadmapId/suggestions/milestones", () => {
it("returns 503 when generation times out", async () => {
// Import the mocked module
const mod = await import("./roadmap-suggestions.js");
// Create an instance of the mocked ServiceUnavailableError
const error = new mod.ServiceUnavailableError("AI suggestion generation timed out. Please try again.");
// Mock to throw ServiceUnavailableError with timeout message
(mod.generateMilestoneSuggestions as ReturnType<typeof vi.fn>).mockRejectedValue(error);
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test Roadmap" });
const response = await performRequest(
app,
"POST",
"/api/roadmaps/" + roadmap.id + "/suggestions/milestones",
JSON.stringify({ goalPrompt: "Build a platform", count: 5 }),
{ "Content-Type": "application/json" }
);
expect(response.status).toBe(503);
expect(response.body.error).toContain("timed out");
});
});
describe("POST /api/roadmaps/milestones/:milestoneId/suggestions/features", () => {
it("returns 503 when generation times out", async () => {
// Import the mocked module - vi.mocked helps with type inference
const mod = vi.mocked(await import("./roadmap-suggestions.js"));
// Create an instance of the mocked ServiceUnavailableError
const error = new mod.ServiceUnavailableError("AI suggestion generation timed out. Please try again.");
// Mock to throw ServiceUnavailableError with timeout message
mod.generateFeatureSuggestions.mockRejectedValue(error);
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test Roadmap" });
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "Phase 1" });
const response = await performRequest(
app,
"POST",
"/api/roadmaps/milestones/" + milestone.id + "/suggestions/features",
JSON.stringify({ count: 5 }),
{ "Content-Type": "application/json" }
);
expect(response.status).toBe(503);
expect(response.body.error).toContain("timed out");
});
});
});

View File

@@ -50,6 +50,7 @@ import {
ValidationError as SuggestionValidationError,
ParseError as SuggestionParseError,
ServiceUnavailableError as SuggestionServiceUnavailableError,
SUGGESTION_TIMEOUT_MS,
} from "./roadmap-suggestions.js";
import { getOrCreateProjectStore } from "./project-store-resolver.js";
@@ -452,6 +453,21 @@ export function createRoadmapRouter(store: TaskStore): Router {
* Generate milestone suggestions using AI.
*/
router.post("/:roadmapId/suggestions/milestones", async (req, res) => {
// Route-level timeout as safety net (slightly longer than internal timeout)
const ROUTE_TIMEOUT_MS = SUGGESTION_TIMEOUT_MS + 10_000;
let routeTimedOut = false;
const routeTimeoutId = setTimeout(() => {
routeTimedOut = true;
if (!res.headersSent) {
res.status(503).json({ error: "Request timed out" });
}
}, ROUTE_TIMEOUT_MS);
// Clean up timeout on connection close
res.on("close", () => {
if (routeTimeoutId) clearTimeout(routeTimeoutId);
});
try {
const roadmapStore = getScopedStore().getRoadmapStore();
const scopedStore = getScopedStore();
@@ -500,6 +516,8 @@ export function createRoadmapRouter(store: TaskStore): Router {
} catch (err) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to generate milestone suggestions");
} finally {
if (routeTimeoutId) clearTimeout(routeTimeoutId);
}
});
@@ -508,6 +526,21 @@ export function createRoadmapRouter(store: TaskStore): Router {
* Generate feature suggestions using AI.
*/
router.post("/milestones/:milestoneId/suggestions/features", async (req, res) => {
// Route-level timeout as safety net (slightly longer than internal timeout)
const ROUTE_TIMEOUT_MS = SUGGESTION_TIMEOUT_MS + 10_000;
let routeTimedOut = false;
const routeTimeoutId = setTimeout(() => {
routeTimedOut = true;
if (!res.headersSent) {
res.status(503).json({ error: "Request timed out" });
}
}, ROUTE_TIMEOUT_MS);
// Clean up timeout on connection close
res.on("close", () => {
if (routeTimeoutId) clearTimeout(routeTimeoutId);
});
try {
const roadmapStore = getScopedStore().getRoadmapStore();
const scopedStore = getScopedStore();
@@ -576,6 +609,8 @@ export function createRoadmapRouter(store: TaskStore): Router {
} catch (err) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to generate feature suggestions");
} finally {
if (routeTimeoutId) clearTimeout(routeTimeoutId);
}
});

View File

@@ -6,6 +6,8 @@ import {
generateFeatureSuggestions,
ValidationError,
ParseError,
ServiceUnavailableError,
SUGGESTION_TIMEOUT_MS,
__resetSuggestionState,
__setCreateKbAgent,
} from "./roadmap-suggestions";
@@ -617,6 +619,43 @@ describe("roadmap-suggestions", () => {
})
);
});
it("times out when AI prompt hangs", async () => {
// Create a mock session whose prompt hangs (never resolves)
const mockSession = {
prompt: vi.fn().mockReturnValue(
new Promise<undefined>(() => {
// Never resolves - simulates hanging AI
})
),
dispose: vi.fn(),
state: {
messages: [],
},
};
const mockCreateKbAgent = vi.fn().mockResolvedValue({
session: mockSession,
});
__setCreateKbAgent(mockCreateKbAgent);
// Use fake timers
vi.useFakeTimers();
try {
const promise = generateMilestoneSuggestions("Test goal", 5, rootDir);
// Advance timers past the timeout threshold
await vi.advanceTimersByTimeAsync(SUGGESTION_TIMEOUT_MS + 100);
// The promise should reject with ServiceUnavailableError
await expect(promise).rejects.toThrow(ServiceUnavailableError);
await expect(promise).rejects.toThrow(/timed out/i);
} finally {
vi.useRealTimers();
}
});
});
describe("validateFeatureSuggestionInput", () => {
@@ -1406,5 +1445,42 @@ describe("roadmap-suggestions", () => {
expect(suggestions[0]).toEqual({ title: "Valid", description: undefined });
expect(suggestions[1]).toEqual({ title: "Also Valid", description: undefined });
});
it("times out when AI prompt hangs", async () => {
// Create a mock session whose prompt hangs (never resolves)
const mockSession = {
prompt: vi.fn().mockReturnValue(
new Promise<undefined>(() => {
// Never resolves - simulates hanging AI
})
),
dispose: vi.fn(),
state: {
messages: [],
},
};
const mockCreateKbAgent = vi.fn().mockResolvedValue({
session: mockSession,
});
__setCreateKbAgent(mockCreateKbAgent);
// Use fake timers
vi.useFakeTimers();
try {
const promise = generateFeatureSuggestions(baseContext, 5, undefined, rootDir);
// Advance timers past the timeout threshold
await vi.advanceTimersByTimeAsync(SUGGESTION_TIMEOUT_MS + 100);
// The promise should reject with ServiceUnavailableError
await expect(promise).rejects.toThrow(ServiceUnavailableError);
await expect(promise).rejects.toThrow(/timed out/i);
} finally {
vi.useRealTimers();
}
});
});
});

View File

@@ -13,8 +13,6 @@
* - Error mapping (validation 400, not found 404, AI/parser 500/503)
*/
import { randomUUID } from "node:crypto";
// Dynamic import for @fusion/engine to avoid resolution issues in test environment
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let createKbAgent: any;
@@ -97,6 +95,9 @@ Do NOT include any markdown formatting, code fences, or additional text. Only ou
/** Maximum length for goal prompt */
const MAX_GOAL_PROMPT_LENGTH = 4000;
/** Timeout for AI suggestion generation (2 minutes) */
export const SUGGESTION_TIMEOUT_MS = 120_000;
/** Default number of suggestions to generate */
const DEFAULT_SUGGESTION_COUNT = 5;
@@ -364,122 +365,132 @@ export async function generateMilestoneSuggestions(
throw new Error("rootDir is required for AI-powered suggestion generation");
}
// Create a unique session ID for this generation
const sessionId = randomUUID();
// Race AI generation against a timeout to prevent hanging requests
const result = await Promise.race([
(async () => {
let agent: ReturnType<typeof createKbAgent> | undefined;
let agent: ReturnType<typeof createKbAgent> | undefined;
try {
// Create AI agent with milestone suggestion system prompt
agent = await createKbAgent({
cwd: rootDir,
systemPrompt: MILESTONE_SUGGESTION_SYSTEM_PROMPT,
tools: "readonly",
...(modelProvider && modelId
? {
defaultProvider: modelProvider,
defaultModelId: modelId,
}
: {}),
onThinking: () => {
// Ignore thinking output for milestone suggestions
},
onText: () => {
// Ignore incremental text
},
});
// Send the goal prompt with count instruction
const userMessage = `Please suggest ${count} milestones for the following roadmap goal:\n\n${goalPrompt.trim()}`;
// Get response from AI
await agent.session.prompt(userMessage);
// Extract response text from agent state
interface AgentMessage {
role: string;
content?: string | Array<{ type: string; text: string }>;
}
const lastMessage = (agent.session.state.messages as AgentMessage[])
.filter((m: AgentMessage) => m.role === "assistant")
.pop();
let responseText = "";
if (lastMessage?.content) {
if (typeof lastMessage.content === "string") {
responseText = lastMessage.content;
} else if (Array.isArray(lastMessage.content)) {
responseText = lastMessage.content
.filter((c: { type: string; text: string }): c is { type: "text"; text: string } => c.type === "text")
.map((c: { type: string; text: string }) => c.text)
.join("");
}
}
// Parse the JSON response with retry
let suggestions: MilestoneSuggestion[] | undefined;
let lastError: Error | undefined;
for (let attempt = 0; attempt <= MAX_PARSE_RETRIES; attempt++) {
try {
suggestions = parseMilestoneSuggestions(responseText);
break;
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
// Create AI agent with milestone suggestion system prompt
agent = await createKbAgent({
cwd: rootDir,
systemPrompt: MILESTONE_SUGGESTION_SYSTEM_PROMPT,
tools: "readonly",
...(modelProvider && modelId
? {
defaultProvider: modelProvider,
defaultModelId: modelId,
}
: {}),
onThinking: () => {
// Ignore thinking output for milestone suggestions
},
onText: () => {
// Ignore incremental text
},
});
if (attempt < MAX_PARSE_RETRIES) {
// Retry: ask the AI to reformat as clean JSON
// Send the goal prompt with count instruction
const userMessage = `Please suggest ${count} milestones for the following roadmap goal:\n\n${goalPrompt.trim()}`;
// Get response from AI
await agent.session.prompt(userMessage);
// Extract response text from agent state
interface AgentMessage {
role: string;
content?: string | Array<{ type: string; text: string }>;
}
const lastMessage = (agent.session.state.messages as AgentMessage[])
.filter((m: AgentMessage) => m.role === "assistant")
.pop();
let responseText = "";
if (lastMessage?.content) {
if (typeof lastMessage.content === "string") {
responseText = lastMessage.content;
} else if (Array.isArray(lastMessage.content)) {
responseText = lastMessage.content
.filter((c: { type: string; text: string }): c is { type: "text"; text: string } => c.type === "text")
.map((c: { type: string; text: string }) => c.text)
.join("");
}
}
// Parse the JSON response with retry
let suggestions: MilestoneSuggestion[] | undefined;
let lastError: Error | undefined;
for (let attempt = 0; attempt <= MAX_PARSE_RETRIES; attempt++) {
try {
await agent.session.prompt(
"Your previous response could not be parsed as JSON. " +
"Please respond with ONLY a JSON array of milestone suggestions in this format: " +
'[{"title": "Milestone Title", "description": "Brief description"}, ...]. ' +
"No markdown, no explanation, just the JSON array."
);
suggestions = parseMilestoneSuggestions(responseText);
break;
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
// Get the new response text
const retryMessage = (agent.session.state.messages as AgentMessage[])
.filter((m: AgentMessage) => m.role === "assistant")
.pop();
if (attempt < MAX_PARSE_RETRIES) {
// Retry: ask the AI to reformat as clean JSON
try {
await agent.session.prompt(
"Your previous response could not be parsed as JSON. " +
"Please respond with ONLY a JSON array of milestone suggestions in this format: " +
'[{"title": "Milestone Title", "description": "Brief description"}, ...]. ' +
"No markdown, no explanation, just the JSON array."
);
let retryText = "";
if (retryMessage?.content) {
if (typeof retryMessage.content === "string") {
retryText = retryMessage.content;
} else if (Array.isArray(retryMessage.content)) {
retryText = retryMessage.content
.filter((c: { type: string; text: string }): c is { type: "text"; text: string } => c.type === "text")
.map((c: { type: string; text: string }) => c.text)
.join("");
// Get the new response text
const retryMessage = (agent.session.state.messages as AgentMessage[])
.filter((m: AgentMessage) => m.role === "assistant")
.pop();
let retryText = "";
if (retryMessage?.content) {
if (typeof retryMessage.content === "string") {
retryText = retryMessage.content;
} else if (Array.isArray(retryMessage.content)) {
retryText = retryMessage.content
.filter((c: { type: string; text: string }): c is { type: "text"; text: string } => c.type === "text")
.map((c: { type: string; text: string }) => c.text)
.join("");
}
}
responseText = retryText;
} catch {
// Retry prompt itself failed — give up
break;
}
}
responseText = retryText;
}
}
if (!suggestions) {
throw new ParseError(
`Failed to parse AI response after ${MAX_PARSE_RETRIES + 1} attempts: ${lastError?.message || "Unknown error"}`
);
}
// Limit to requested count
return suggestions.slice(0, count);
} finally {
// Always dispose the agent session (inside the raced promise so cleanup happens when this settles)
if (agent) {
try {
agent.session.dispose?.();
} catch {
// Retry prompt itself failed — give up
break;
// Ignore disposal errors
}
}
}
}
})(),
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new ServiceUnavailableError("AI suggestion generation timed out. Please try again.")),
SUGGESTION_TIMEOUT_MS
)
),
]);
if (!suggestions) {
throw new ParseError(
`Failed to parse AI response after ${MAX_PARSE_RETRIES + 1} attempts: ${lastError?.message || "Unknown error"}`
);
}
// Limit to requested count
return suggestions.slice(0, count);
} finally {
// Always dispose the agent session
if (agent) {
try {
agent.session.dispose?.();
} catch {
// Ignore disposal errors
}
}
}
return result;
}
// ═══════════════════════════════════════════════════════════════════════════════
@@ -722,122 +733,135 @@ export async function generateFeatureSuggestions(
milestoneContextStr
);
let agent: ReturnType<typeof createKbAgent> | undefined;
// Race AI generation against a timeout to prevent hanging requests
const result = await Promise.race([
(async () => {
let agent: ReturnType<typeof createKbAgent> | undefined;
try {
// Create AI agent with feature suggestion system prompt
agent = await createKbAgent({
cwd: rootDir,
systemPrompt,
tools: "readonly",
...(modelProvider && modelId
? {
defaultProvider: modelProvider,
defaultModelId: modelId,
}
: {}),
onThinking: () => {
// Ignore thinking output for feature suggestions
},
onText: () => {
// Ignore incremental text
},
});
// Build the user message
let userMessage = `Please suggest ${count} features for the milestone described above.`;
if (prompt && prompt.trim()) {
userMessage += `\n\nAdditional guidance:\n${prompt.trim()}`;
}
// Get response from AI
await agent.session.prompt(userMessage);
// Extract response text from agent state
interface AgentMessage {
role: string;
content?: string | Array<{ type: string; text: string }>;
}
const lastMessage = (agent.session.state.messages as AgentMessage[])
.filter((m: AgentMessage) => m.role === "assistant")
.pop();
let responseText = "";
if (lastMessage?.content) {
if (typeof lastMessage.content === "string") {
responseText = lastMessage.content;
} else if (Array.isArray(lastMessage.content)) {
responseText = lastMessage.content
.filter((c: { type: string; text: string }): c is { type: "text"; text: string } => c.type === "text")
.map((c: { type: string; text: string }) => c.text)
.join("");
}
}
// Parse the JSON response with retry
let suggestions: FeatureSuggestion[] | undefined;
let lastError: Error | undefined;
for (let attempt = 0; attempt <= MAX_PARSE_RETRIES; attempt++) {
try {
suggestions = parseFeatureSuggestions(responseText);
break;
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
// Create AI agent with feature suggestion system prompt
agent = await createKbAgent({
cwd: rootDir,
systemPrompt,
tools: "readonly",
...(modelProvider && modelId
? {
defaultProvider: modelProvider,
defaultModelId: modelId,
}
: {}),
onThinking: () => {
// Ignore thinking output for feature suggestions
},
onText: () => {
// Ignore incremental text
},
});
if (attempt < MAX_PARSE_RETRIES) {
// Retry: ask the AI to reformat as clean JSON
// Build the user message
let userMessage = `Please suggest ${count} features for the milestone described above.`;
if (prompt && prompt.trim()) {
userMessage += `\n\nAdditional guidance:\n${prompt.trim()}`;
}
// Get response from AI
await agent.session.prompt(userMessage);
// Extract response text from agent state
interface AgentMessage {
role: string;
content?: string | Array<{ type: string; text: string }>;
}
const lastMessage = (agent.session.state.messages as AgentMessage[])
.filter((m: AgentMessage) => m.role === "assistant")
.pop();
let responseText = "";
if (lastMessage?.content) {
if (typeof lastMessage.content === "string") {
responseText = lastMessage.content;
} else if (Array.isArray(lastMessage.content)) {
responseText = lastMessage.content
.filter((c: { type: string; text: string }): c is { type: "text"; text: string } => c.type === "text")
.map((c: { type: string; text: string }) => c.text)
.join("");
}
}
// Parse the JSON response with retry
let suggestions: FeatureSuggestion[] | undefined;
let lastError: Error | undefined;
for (let attempt = 0; attempt <= MAX_PARSE_RETRIES; attempt++) {
try {
await agent.session.prompt(
"Your previous response could not be parsed as JSON. " +
"Please respond with ONLY a JSON array of feature suggestions in this format: " +
'[{"title": "Feature Title", "description": "Brief description"}, ...]. ' +
"No markdown, no explanation, just the JSON array."
);
suggestions = parseFeatureSuggestions(responseText);
break;
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
// Get the new response text
const retryMessage = (agent.session.state.messages as AgentMessage[])
.filter((m: AgentMessage) => m.role === "assistant")
.pop();
if (attempt < MAX_PARSE_RETRIES) {
// Retry: ask the AI to reformat as clean JSON
try {
await agent.session.prompt(
"Your previous response could not be parsed as JSON. " +
"Please respond with ONLY a JSON array of feature suggestions in this format: " +
'[{"title": "Feature Title", "description": "Brief description"}, ...]. ' +
"No markdown, no explanation, just the JSON array."
);
let retryText = "";
if (retryMessage?.content) {
if (typeof retryMessage.content === "string") {
retryText = retryMessage.content;
} else if (Array.isArray(retryMessage.content)) {
retryText = retryMessage.content
.filter((c: { type: string; text: string }): c is { type: "text"; text: string } => c.type === "text")
.map((c: { type: string; text: string }) => c.text)
.join("");
// Get the new response text
const retryMessage = (agent.session.state.messages as AgentMessage[])
.filter((m: AgentMessage) => m.role === "assistant")
.pop();
let retryText = "";
if (retryMessage?.content) {
if (typeof retryMessage.content === "string") {
retryText = retryMessage.content;
} else if (Array.isArray(retryMessage.content)) {
retryText = retryMessage.content
.filter((c: { type: string; text: string }): c is { type: "text"; text: string } => c.type === "text")
.map((c: { type: string; text: string }) => c.text)
.join("");
}
}
responseText = retryText;
} catch {
// Retry prompt itself failed — give up
break;
}
}
responseText = retryText;
}
}
if (!suggestions) {
throw new ParseError(
`Failed to parse AI response after ${MAX_PARSE_RETRIES + 1} attempts: ${lastError?.message || "Unknown error"}`
);
}
// Limit to requested count
return suggestions.slice(0, count);
} finally {
// Always dispose the agent session (inside the raced promise so cleanup happens when this settles)
if (agent) {
try {
agent.session.dispose?.();
} catch {
// Retry prompt itself failed — give up
break;
// Ignore disposal errors
}
}
}
}
})(),
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new ServiceUnavailableError("AI suggestion generation timed out. Please try again.")),
SUGGESTION_TIMEOUT_MS
)
),
]);
if (!suggestions) {
throw new ParseError(
`Failed to parse AI response after ${MAX_PARSE_RETRIES + 1} attempts: ${lastError?.message || "Unknown error"}`
);
}
// Limit to requested count
return suggestions.slice(0, count);
} finally {
// Always dispose the agent session
if (agent) {
try {
agent.session.dispose?.();
} catch {
// Ignore disposal errors
}
}
}
return result;
}
// ── Custom Errors ───────────────────────────────────────────────────────────