Files
fusion/packages/dashboard/src/pr-metadata-generator.ts
gsxdsm cf80622875 FN-5993: prevent PR metadata generation hangs
Bound PR AI metadata generation so the PR dialog falls back cleanly instead of hanging indefinitely.

- add a 60s timeout and abort-aware fallback path to dashboard PR metadata generation
- forward request abort signals from the PR metadata route and avoid writing responses after disconnects
- cover timeout/abort behavior in PR metadata generator and route tests
- document the bounded fallback behavior in the dashboard guide

Files changed:
 docs/dashboard-guide.md                            |   1 +
 .../src/__tests__/pr-metadata-generator.test.ts    |  93 +++++++++-
 ...it-github.pr-options-preflight-metadata.test.ts |   8 +-
 packages/dashboard/src/pr-metadata-generator.ts    | 199 ++++++++++++++-------
 .../dashboard/src/routes/register-git-github.ts    |  10 ++
 5 files changed, 235 insertions(+), 76 deletions(-)

Fusion-Task-Id: FN-5993

Fusion-Task-Lineage: fc49c15a-2359-4d14-b0ae-fed4aecde2fb
2026-06-07 20:38:53 -07:00

308 lines
8.3 KiB
TypeScript

import { promisify } from "node:util";
import { exec as execCb } from "node:child_process";
import { access, readFile } from "node:fs/promises";
import { join } from "node:path";
import type { GlobalSettings, ProjectSettings, Settings, Task } from "@fusion/core";
import { resolveTaskPlanningModel } from "@fusion/core";
import { createFnAgent } from "@fusion/engine";
const execAsync = promisify(execCb);
const PR_METADATA_TIMEOUT_MS = 60_000;
export interface GeneratedPrMetadata {
title: string;
body: string;
templateUsed: boolean;
}
interface AiMetadataResult {
title: string;
summary: string;
changes: string;
testing: string;
linkedTask: string;
}
function buildFallback(task: Task): GeneratedPrMetadata {
return {
title: task.title ?? task.id,
body: [
"## Summary",
"",
task.description?.trim() || "Summary unavailable.",
"",
"## Changes",
"",
"- Details unavailable.",
"",
"## Testing",
"",
"- Not provided.",
"",
"## Linked Task",
"",
`Closes ${task.id}`,
].join("\n"),
templateUsed: false,
};
}
function asString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function parseAiResult(raw: string): AiMetadataResult | null {
try {
const parsed = JSON.parse(raw) as Partial<AiMetadataResult>;
const title = asString(parsed.title);
const summary = asString(parsed.summary);
const changes = asString(parsed.changes);
const testing = asString(parsed.testing);
const linkedTask = asString(parsed.linkedTask);
if (!title || !summary || !changes || !testing) {
return null;
}
return { title, summary, changes, testing, linkedTask };
} catch {
return null;
}
}
function buildBody(result: AiMetadataResult, taskId: string): string {
const linkedTaskLines = [result.linkedTask, `Closes ${taskId}`].filter(Boolean);
return [
"## Summary",
"",
result.summary,
"",
"## Changes",
"",
result.changes,
"",
"## Testing",
"",
result.testing,
"",
"## Linked Task",
"",
...linkedTaskLines,
].join("\n");
}
function fillTemplate(template: string, result: AiMetadataResult, taskId: string): string {
const known = new Map<string, string>([
["summary", result.summary],
["changes", result.changes],
["testing", result.testing],
["linked task", `${result.linkedTask}\n\nCloses ${taskId}`.trim()],
]);
const lines = template.split(/\r?\n/);
let i = 0;
const out: string[] = [];
while (i < lines.length) {
const line = lines[i];
const headingMatch = line.match(/^(##+)\s+(.*)$/);
if (!headingMatch) {
out.push(line);
i += 1;
continue;
}
const heading = headingMatch[2].trim().toLowerCase();
const replacement = known.get(heading);
out.push(line);
i += 1;
const sectionBody: string[] = [];
while (i < lines.length && !/^(##+)\s+/.test(lines[i])) {
sectionBody.push(lines[i]);
i += 1;
}
if (replacement) {
out.push("");
out.push(...replacement.split("\n"));
} else {
out.push(...sectionBody);
}
}
if (!out.join("\n").includes(`Closes ${taskId}`)) {
out.push("", "## Linked Task", "", `Closes ${taskId}`);
}
return out.join("\n");
}
async function runCommand(command: string, cwd: string, signal?: AbortSignal): Promise<string> {
const { stdout } = await execAsync(command, {
cwd,
timeout: 15_000,
maxBuffer: 10 * 1024 * 1024,
signal,
});
return stdout.trim();
}
function createAbortError(): Error {
const error = new Error("PR metadata generation aborted");
error.name = "AbortError";
return error;
}
function throwIfAborted(signal?: AbortSignal): void {
if (!signal?.aborted) {
return;
}
throw signal.reason instanceof Error ? signal.reason : createAbortError();
}
function waitForAbort(signal: AbortSignal): Promise<never> {
return new Promise((_, reject) => {
if (signal.aborted) {
reject(signal.reason instanceof Error ? signal.reason : createAbortError());
return;
}
signal.addEventListener(
"abort",
() => reject(signal.reason instanceof Error ? signal.reason : createAbortError()),
{ once: true },
);
});
}
function isAbortLikeError(error: unknown): boolean {
return Boolean(
error
&& typeof error === "object"
&& ("name" in error ? (error as { name?: unknown }).name === "AbortError" : false),
);
}
async function resolveBaseBranch(task: Task, repoRoot: string, signal?: AbortSignal): Promise<string> {
if (task.prInfo?.baseBranch) {
return task.prInfo.baseBranch;
}
try {
const stdout = await runCommand("gh repo view --json defaultBranchRef -q .defaultBranchRef.name", repoRoot, signal);
if (stdout) return stdout;
} catch {
// fallback below
}
return "main";
}
export async function generatePrMetadata(input: {
task: Task;
repoRoot: string;
settings: ProjectSettings & GlobalSettings;
signal?: AbortSignal;
timeoutMs?: number;
}): Promise<GeneratedPrMetadata> {
const { task, repoRoot, settings, signal, timeoutMs = PR_METADATA_TIMEOUT_MS } = input;
const fallback = buildFallback(task);
const controller = new AbortController();
const abortFromCaller = () => controller.abort(signal?.reason instanceof Error ? signal.reason : createAbortError());
const timeoutId = setTimeout(() => controller.abort(createAbortError()), timeoutMs);
const combinedSignal = controller.signal;
const callerSignalWasActive = Boolean(signal && !signal.aborted);
if (signal) {
if (signal.aborted) {
abortFromCaller();
} else {
signal.addEventListener("abort", abortFromCaller, { once: true });
}
}
try {
throwIfAborted(combinedSignal);
const baseBranch = await resolveBaseBranch(task, repoRoot, combinedSignal);
const [logOut, diffStatOut] = await Promise.all([
runCommand(`git log --no-merges ${baseBranch}..HEAD --format=%s%n%b`, repoRoot, combinedSignal).catch(() => ""),
runCommand(`git diff --stat ${baseBranch}..HEAD`, repoRoot, combinedSignal).catch(() => ""),
]);
let promptContent = "";
try {
const promptPath = join(repoRoot, ".fusion", "tasks", task.id, "PROMPT.md");
promptContent = (await readFile(promptPath, "utf8")).trim();
} catch {
promptContent = "";
}
const templatePath = join(repoRoot, ".github", "pull_request_template.md");
const templateExists = await access(templatePath).then(() => true).catch(() => false);
const template = templateExists ? await readFile(templatePath, "utf8") : "";
const model = resolveTaskPlanningModel(task, settings as Partial<Settings>);
let aiText = "";
const { session } = await createFnAgent({
cwd: repoRoot,
tools: "readonly",
defaultProvider: model.provider,
defaultModelId: model.modelId,
systemPrompt: [
"Generate GitHub PR metadata.",
"Respond with strict JSON only.",
"Schema: {title, summary, changes, testing, linkedTask}",
].join("\n"),
onText: (delta: string) => {
aiText += delta;
},
});
try {
const contextPrompt = [
`Task ID: ${task.id}`,
`Task title: ${task.title}`,
`Task description: ${task.description ?? ""}`,
`Base branch: ${baseBranch}`,
"Commit log:",
logOut || "(none)",
"Diff stat:",
diffStatOut || "(none)",
"Task prompt:",
promptContent || "(none)",
].join("\n\n");
throwIfAborted(combinedSignal);
await Promise.race([
(session.prompt as (prompt: string, options?: { signal?: AbortSignal }) => Promise<unknown>)(contextPrompt, { signal: combinedSignal }),
waitForAbort(combinedSignal),
]);
} finally {
try {
session.dispose();
} catch {
// best effort
}
}
const parsed = parseAiResult(aiText);
if (!parsed) {
return fallback;
}
const body = templateExists ? fillTemplate(template, parsed, task.id) : buildBody(parsed, task.id);
return {
title: parsed.title,
body,
templateUsed: templateExists,
};
} catch (error) {
if (isAbortLikeError(error)) {
return fallback;
}
throw error;
} finally {
clearTimeout(timeoutId);
if (callerSignalWasActive) {
signal?.removeEventListener("abort", abortFromCaller);
}
}
}