feat(FN-3008): notify when model settings fall back to defaults

Merges FN-3008 to add a "fallback-used" notification system: the engine now emits events when AI model fallbacks are triggered, dispatches notifications via ntfy/webhook providers, surfaces a session banner in the dashboard, and exposes a settings toggle to enable or disable these alerts.

Fusion-Task-Id: FN-3008
This commit is contained in:
Fusion
2026-05-02 19:51:05 -07:00
committed by gsxdsm
parent eed181ff80
commit e1c10721a3
26 changed files with 357 additions and 41 deletions

View File

@@ -228,6 +228,15 @@ export class NotificationService {
);
}
async dispatch(eventType: NotificationEvent, payload: NotificationPayload): Promise<void> {
if (!this.notificationsEnabled) {
return;
}
const dedupTaskId = payload.taskId ?? "global";
this.maybeNotify(dedupTaskId, eventType, payload);
}
private createTaskPayload(task: Task, event: NotificationEvent): NotificationPayload {
return {
taskId: task.id,

View File

@@ -33,7 +33,8 @@ type SupportedNtfyEvent =
| "failed"
| "awaiting-approval"
| "awaiting-user-review"
| "planning-awaiting-input";
| "planning-awaiting-input"
| "fallback-used";
const SUPPORTED_EVENTS = new Set<SupportedNtfyEvent>([
"in-review",
@@ -42,6 +43,7 @@ const SUPPORTED_EVENTS = new Set<SupportedNtfyEvent>([
"awaiting-approval",
"awaiting-user-review",
"planning-awaiting-input",
"fallback-used",
]);
export class NtfyNotificationProvider implements NotificationProvider {
@@ -92,8 +94,9 @@ export class NtfyNotificationProvider implements NotificationProvider {
};
}
const taskId = payload.taskId ?? "unknown-task";
const taskLike = {
id: payload.taskId,
id: taskId,
title: payload.taskTitle,
description: payload.taskDescription ?? "",
} as Pick<Task, "id" | "title" | "description"> as Task;
@@ -107,35 +110,40 @@ export class NtfyNotificationProvider implements NotificationProvider {
const contentByEvent: Record<SupportedNtfyEvent, { title: string; message: string; priority: "default" | "high" }> = {
"in-review": {
title: `Task ${payload.taskId} completed`,
title: `Task ${taskId} completed`,
message: `Task "${identifier}" is ready for review`,
priority: "default",
},
merged: {
title: `Task ${payload.taskId} merged`,
title: `Task ${taskId} merged`,
message: `Task "${identifier}" has been merged to main`,
priority: "default",
},
failed: {
title: `Task ${payload.taskId} failed`,
title: `Task ${taskId} failed`,
message: `Task "${identifier}" has failed and needs attention`,
priority: "high",
},
"awaiting-approval": {
title: `Plan needs approval for ${payload.taskId}`,
title: `Plan needs approval for ${taskId}`,
message: `Task "${identifier}" needs your approval before it can proceed`,
priority: "high",
},
"awaiting-user-review": {
title: `User review needed for ${payload.taskId}`,
title: `User review needed for ${taskId}`,
message: `Task "${identifier}" needs human review before it can proceed`,
priority: "high",
},
"planning-awaiting-input": {
title: `Planning input needed for ${payload.taskId}`,
title: `Planning input needed for ${taskId}`,
message: `Task "${identifier}" is awaiting your input during planning`,
priority: "high",
},
"fallback-used": {
title: `Fallback model used${payload.taskId ? ` for ${payload.taskId}` : ""}`,
message: `Fusion switched from ${String(payload.metadata?.primaryModel ?? "primary model")} to ${String(payload.metadata?.fallbackModel ?? "fallback model")} after a retryable failure (${String(payload.metadata?.triggerPoint ?? "unknown trigger")}).`,
priority: "high",
},
};
const content = contentByEvent[event as SupportedNtfyEvent];

View File

@@ -133,6 +133,8 @@ export class WebhookNotificationProvider implements NotificationProvider {
return `Task "${identifier}" is awaiting your input during planning`;
case "gridlock":
return "Pipeline gridlocked";
case "fallback-used":
return `Fusion recovered by switching from ${String(payload.metadata?.primaryModel ?? "primary model")} to ${String(payload.metadata?.fallbackModel ?? "fallback model")} (${String(payload.metadata?.triggerPoint ?? "unknown trigger")})`;
default:
return `Event "${event}" for task ${identifier}`;
}
@@ -145,7 +147,7 @@ export class WebhookNotificationProvider implements NotificationProvider {
const description = payload.taskDescription ?? "";
const snippet = description.length > 200 ? `${description.slice(0, 200)}...` : description;
return `${payload.taskId}: ${snippet}`;
return `${payload.taskId ?? "unknown-task"}: ${snippet}`;
}
private formatPayload(payload: NotificationPayload, message: string): Record<string, unknown> {