From 6790c73509ddccf1cacdca23f118dc0b7eec6ad1 Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:08:16 -0400 Subject: [PATCH 1/3] [Improve] Surface routing failures instead of silently showing the picker Routing fallbacks caused by infrastructure errors (provider outages, key limits) were indistinguishable from the router legitimately declining to pick a workspace: the reason string was dropped, users just saw the manual picker, and the router debug channel showed "(none)". - Add RoutingFallbackCause ('exception' | 'model_decision') to fallback decisions and set it at every construction site; include cause= in the routing fallback logs - Show a routing-unavailable warning above the Slack manual picker when the fallback came from an exception (both the mention flow and channel auto-start), keeping raw provider errors out of user threads - Post a router-fallback diagnostic to the configured router debug channel at fallback time, including the full failure reason --- .../slack/events/auto-route-fallback.test.ts | 73 +++++++++ .../slack/events/auto-route-fallback.ts | 8 + .../router/__tests__/router-service.test.ts | 1 + .../cloud-agents/src/server/router/index.ts | 1 + .../src/server/router/router-service.ts | 8 + .../cloud-agents/src/server/router/types.ts | 23 ++- .../handle-retry-failed-task.test.ts | 1 + .../__tests__/mcp-setup-suggestion.test.ts | 1 + .../__tests__/show-task-configuration.test.ts | 104 +++++++++++++ packages/slack/src/block-kit.ts | 72 +++++++-- packages/slack/src/router-debug.ts | 140 +++++++++++++++++- .../slack/src/start-auto-routed-slack-task.ts | 30 +++- 12 files changed, 449 insertions(+), 13 deletions(-) diff --git a/apps/api/src/handlers/slack/events/auto-route-fallback.test.ts b/apps/api/src/handlers/slack/events/auto-route-fallback.test.ts index a94c5ac14..06a8feafc 100644 --- a/apps/api/src/handlers/slack/events/auto-route-fallback.test.ts +++ b/apps/api/src/handlers/slack/events/auto-route-fallback.test.ts @@ -4,6 +4,7 @@ const { showTaskConfigurationMock } = vi.hoisted(() => ({ vi.mock('@roomote/slack', () => ({ showTaskConfiguration: showTaskConfigurationMock, + SLACK_ROUTING_UNAVAILABLE_NOTICE: '⚠️ routing unavailable notice', })); describe('auto-route-fallback', () => { @@ -58,6 +59,78 @@ describe('auto-route-fallback', () => { }); }); + it('passes the routing-unavailable warning through when routing failed with an exception', async () => { + const { showManualPickerForAutoRouteFallback } = + await import('./auto-route-fallback.js'); + + const shown = await showManualPickerForAutoRouteFallback({ + result: { + status: 'not_started', + code: 'routing_fallback', + threadId: '111.000', + message: 'Slack auto-routing needs manual environment selection.', + routingFallback: { + cause: 'exception', + reason: + 'OpenCode structured prompt failed: APIError: Key limit exceeded', + }, + }, + event: { + type: 'app_mention', + channel: 'C123', + user: 'U123', + text: '<@BOT> investigate this', + ts: '111.000', + }, + slackInstallation: { teamId: 'T123' } as never, + userMapping: { userId: 'user_123' } as never, + slack: {} as never, + }); + + expect(shown).toBe(true); + expect(showTaskConfigurationMock).toHaveBeenCalledWith( + expect.objectContaining({ + skipRouting: true, + routingFailureNoticeText: '⚠️ routing unavailable notice', + }), + ); + }); + + it('does not pass a warning for model-decided routing fallbacks', async () => { + const { showManualPickerForAutoRouteFallback } = + await import('./auto-route-fallback.js'); + + const shown = await showManualPickerForAutoRouteFallback({ + result: { + status: 'not_started', + code: 'routing_fallback', + threadId: '111.000', + message: 'Slack auto-routing needs manual environment selection.', + routingFallback: { + cause: 'model_decision', + reason: 'Could not map routed environment.', + }, + }, + event: { + type: 'app_mention', + channel: 'C123', + user: 'U123', + text: '<@BOT> investigate this', + ts: '111.000', + }, + slackInstallation: { teamId: 'T123' } as never, + userMapping: { userId: 'user_123' } as never, + slack: {} as never, + }); + + expect(shown).toBe(true); + expect(showTaskConfigurationMock).toHaveBeenCalledWith( + expect.not.objectContaining({ + routingFailureNoticeText: expect.anything(), + }), + ); + }); + it('does not show the picker for non-routing failures or missing user mappings', async () => { const { showManualPickerForAutoRouteFallback } = await import('./auto-route-fallback.js'); diff --git a/apps/api/src/handlers/slack/events/auto-route-fallback.ts b/apps/api/src/handlers/slack/events/auto-route-fallback.ts index e6f5a63d6..d161dd9b0 100644 --- a/apps/api/src/handlers/slack/events/auto-route-fallback.ts +++ b/apps/api/src/handlers/slack/events/auto-route-fallback.ts @@ -1,6 +1,7 @@ import type { SlackInstallation, SlackUserMapping } from '@roomote/db/server'; import { showTaskConfiguration, + SLACK_ROUTING_UNAVAILABLE_NOTICE, type SlackEvent, type SlackNotifier, type StartAutoRoutedSlackTaskResult, @@ -54,6 +55,10 @@ export async function showManualPickerForAutoRouteFallback(params: { : {}), }; + const routingFailedFromException = + params.result.status === 'not_started' && + params.result.routingFallback?.cause === 'exception'; + await showTaskConfiguration({ event, slackInstallation: params.slackInstallation as SlackInstallation, @@ -62,6 +67,9 @@ export async function showManualPickerForAutoRouteFallback(params: { skipRouting: true, skipMcpSetupSuggestion: true, processingReactionName: params.processingReactionName, + ...(routingFailedFromException + ? { routingFailureNoticeText: SLACK_ROUTING_UNAVAILABLE_NOTICE } + : {}), }); return true; diff --git a/packages/cloud-agents/src/server/router/__tests__/router-service.test.ts b/packages/cloud-agents/src/server/router/__tests__/router-service.test.ts index d07fc2de8..45b60f01c 100644 --- a/packages/cloud-agents/src/server/router/__tests__/router-service.test.ts +++ b/packages/cloud-agents/src/server/router/__tests__/router-service.test.ts @@ -294,6 +294,7 @@ describe('routeTask', () => { status: 'fallback', reason: 'OpenCode structured prompt failed: StructuredOutputError: failed to satisfy schema', + cause: 'exception', debug: { phase: 'fallback', toolsUsed: [], diff --git a/packages/cloud-agents/src/server/router/index.ts b/packages/cloud-agents/src/server/router/index.ts index 9731c71bf..151eac73c 100644 --- a/packages/cloud-agents/src/server/router/index.ts +++ b/packages/cloud-agents/src/server/router/index.ts @@ -20,6 +20,7 @@ export type { RoutingResult, PlatformAnswerResult, RoutingDecision, + RoutingFallbackCause, FollowUpIntent, FollowUpClassification, } from './types'; diff --git a/packages/cloud-agents/src/server/router/router-service.ts b/packages/cloud-agents/src/server/router/router-service.ts index fe0ed9f60..bab5f11b8 100644 --- a/packages/cloud-agents/src/server/router/router-service.ts +++ b/packages/cloud-agents/src/server/router/router-service.ts @@ -362,6 +362,7 @@ async function runRoutingDecision( decision: { status: 'fallback', reason: built.fallbackReason, + cause: 'model_decision', }, phase: responseResult.phase ?? 'fallback', model: routingModel, @@ -408,6 +409,7 @@ async function runRoutingDecision( status: 'fallback', reason: error instanceof Error ? error.message : 'Unknown routing error', + cause: 'exception', }, phase: 'fallback', model: routingModel, @@ -499,6 +501,7 @@ export async function routeTask( status: 'fallback', reason: 'Meta question answer was unavailable, and normal routing could not be resolved.', + cause: 'model_decision', }; fallbackDecision.debug = { phase: 'fallback', @@ -513,6 +516,7 @@ export async function routeTask( sourceType: context.source.type, model, phase: 'fallback', + cause: fallbackDecision.cause, toolsUsed, needsExternalLookup, confidence: null, @@ -566,6 +570,7 @@ export async function routeTask( sourceType: context.source.type, model, phase, + cause: decision.cause ?? 'model_decision', toolsUsed, needsExternalLookup, confidence: null, @@ -588,6 +593,7 @@ export async function routeGitHubTask( return { status: 'fallback', reason: 'routeGitHubTask requires a GitHub routing context.', + cause: 'exception', }; } @@ -647,6 +653,7 @@ export async function routeGitHubTask( sourceType: context.source.type, model: routingModel, phase: 'fallback', + cause: 'exception', toolsUsed: [], needsExternalLookup: null, confidence: null, @@ -658,6 +665,7 @@ export async function routeGitHubTask( return { status: 'fallback', reason, + cause: 'exception', debug: { phase: 'fallback', toolsUsed: [], diff --git a/packages/cloud-agents/src/server/router/types.ts b/packages/cloud-agents/src/server/router/types.ts index e1eb58df6..059e50461 100644 --- a/packages/cloud-agents/src/server/router/types.ts +++ b/packages/cloud-agents/src/server/router/types.ts @@ -220,10 +220,24 @@ export interface PlatformAnswerResult { debug?: RoutingDebugInfo; } +/** + * Why a routing attempt ended in fallback. `model_decision` means the router + * ran but declined to pick (ambiguous request, unmapped workspace); + * `exception` means the routing infrastructure itself failed (provider error, + * timeout) and surfaces should tell the user routing is unavailable rather + * than silently showing the manual picker. Absent means `model_decision`. + */ +export type RoutingFallbackCause = 'exception' | 'model_decision'; + export type RoutingDecision = | { status: 'routed'; result: RoutingResult } | { status: 'platform_answer'; result: PlatformAnswerResult } - | { status: 'fallback'; reason: string; debug?: RoutingDebugInfo }; + | { + status: 'fallback'; + reason: string; + cause?: RoutingFallbackCause; + debug?: RoutingDebugInfo; + }; export interface GitHubRoutingResult { reasoning: string; @@ -233,7 +247,12 @@ export interface GitHubRoutingResult { export type GitHubRoutingDecision = | { status: 'routed'; result: GitHubRoutingResult } - | { status: 'fallback'; reason: string; debug?: RoutingDebugInfo }; + | { + status: 'fallback'; + reason: string; + cause?: RoutingFallbackCause; + debug?: RoutingDebugInfo; + }; export interface WorkspaceResponse { workspaceValue: string; diff --git a/packages/slack/src/__tests__/handle-retry-failed-task.test.ts b/packages/slack/src/__tests__/handle-retry-failed-task.test.ts index 6bc0bbb86..9ec67a6b8 100644 --- a/packages/slack/src/__tests__/handle-retry-failed-task.test.ts +++ b/packages/slack/src/__tests__/handle-retry-failed-task.test.ts @@ -102,6 +102,7 @@ vi.mock('@roomote/redis', () => ({ vi.mock('../router-debug', () => ({ postRouterDebugMessage: vi.fn(), + postRouterFallbackDebugMessage: vi.fn(), })); vi.mock('../slack-messages', () => ({ diff --git a/packages/slack/src/__tests__/mcp-setup-suggestion.test.ts b/packages/slack/src/__tests__/mcp-setup-suggestion.test.ts index 9b73e065c..a1e0be327 100644 --- a/packages/slack/src/__tests__/mcp-setup-suggestion.test.ts +++ b/packages/slack/src/__tests__/mcp-setup-suggestion.test.ts @@ -144,6 +144,7 @@ vi.mock('../start-slack-app-mention', () => ({ vi.mock('../router-debug', () => ({ postRouterDebugMessage: vi.fn(), + postRouterFallbackDebugMessage: vi.fn(), })); vi.mock('../slack-messages', () => ({ diff --git a/packages/slack/src/__tests__/show-task-configuration.test.ts b/packages/slack/src/__tests__/show-task-configuration.test.ts index cc5785687..39ec42317 100644 --- a/packages/slack/src/__tests__/show-task-configuration.test.ts +++ b/packages/slack/src/__tests__/show-task-configuration.test.ts @@ -27,6 +27,7 @@ const { addReactionMock, removeReactionMock, postRouterDebugMessageMock, + postRouterFallbackDebugMessageMock, setSlackStartedMessageTsMock, deliveryTrackerCommitMock, } = vi.hoisted(() => ({ @@ -58,6 +59,7 @@ const { addReactionMock: vi.fn(), removeReactionMock: vi.fn(), postRouterDebugMessageMock: vi.fn(), + postRouterFallbackDebugMessageMock: vi.fn(), setSlackStartedMessageTsMock: vi.fn(), deliveryTrackerCommitMock: vi.fn(), })); @@ -160,6 +162,7 @@ vi.mock('@roomote/redis', () => ({ vi.mock('../router-debug', () => ({ postRouterDebugMessage: postRouterDebugMessageMock, + postRouterFallbackDebugMessage: postRouterFallbackDebugMessageMock, })); vi.mock('../slack-messages', () => ({ @@ -199,6 +202,7 @@ import { handleRoutingRejectNo, handleSlackRoutingCorrection, showTaskConfiguration, + SLACK_ROUTING_UNAVAILABLE_NOTICE, } from '../block-kit'; describe('Slack deleted-mention suppression', () => { @@ -664,6 +668,106 @@ describe('Slack deleted-mention suppression', () => { ); }); + it('warns in the picker and posts fallback diagnostics when routing fails with an exception', async () => { + routeTaskMock.mockResolvedValueOnce({ + status: 'fallback', + reason: 'OpenCode structured prompt failed: APIError: Key limit exceeded', + cause: 'exception', + }); + const slack = new SlackNotifier('xoxb-test'); + + await showTaskConfiguration({ + event: { + type: 'app_mention', + channel: 'C123', + user: 'U123', + text: '<@BOT> investigate this', + ts: '111.222', + }, + slackInstallation: { + teamId: 'T123', + } as never, + userMapping: { + userId: 'user_1', + } as never, + slack: slack as never, + }); + + expect(postRouterFallbackDebugMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ + source: 'Slack C123', + reason: + 'OpenCode structured prompt failed: APIError: Key limit exceeded', + cause: 'exception', + }), + ); + expect(JSON.stringify(postMessageMock.mock.calls)).toContain( + SLACK_ROUTING_UNAVAILABLE_NOTICE, + ); + }); + + it('shows the plain picker without a warning when the router declined to pick', async () => { + routeTaskMock.mockResolvedValueOnce({ + status: 'fallback', + reason: + 'Could not map routed environment "Unknown" to an available environment.', + cause: 'model_decision', + }); + const slack = new SlackNotifier('xoxb-test'); + + await showTaskConfiguration({ + event: { + type: 'app_mention', + channel: 'C123', + user: 'U123', + text: '<@BOT> investigate this', + ts: '111.222', + }, + slackInstallation: { + teamId: 'T123', + } as never, + userMapping: { + userId: 'user_1', + } as never, + slack: slack as never, + }); + + expect(postRouterFallbackDebugMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ cause: 'model_decision' }), + ); + expect(JSON.stringify(postMessageMock.mock.calls)).not.toContain( + SLACK_ROUTING_UNAVAILABLE_NOTICE, + ); + }); + + it('shows the routing-unavailable warning above the picker when the caller passes one', async () => { + const slack = new SlackNotifier('xoxb-test'); + + await showTaskConfiguration({ + event: { + type: 'app_mention', + channel: 'C123', + user: 'U123', + text: '<@BOT> investigate this', + ts: '111.222', + }, + slackInstallation: { + teamId: 'T123', + } as never, + userMapping: { + userId: 'user_1', + } as never, + slack: slack as never, + skipRouting: true, + routingFailureNoticeText: SLACK_ROUTING_UNAVAILABLE_NOTICE, + }); + + expect(routeTaskMock).not.toHaveBeenCalled(); + expect(JSON.stringify(postMessageMock.mock.calls)).toContain( + SLACK_ROUTING_UNAVAILABLE_NOTICE, + ); + }); + it('drops the rejected environment kickoff before showing the manual picker', async () => { evalMock.mockResolvedValueOnce( JSON.stringify({ diff --git a/packages/slack/src/block-kit.ts b/packages/slack/src/block-kit.ts index 876eb3484..9aecd3c49 100644 --- a/packages/slack/src/block-kit.ts +++ b/packages/slack/src/block-kit.ts @@ -62,7 +62,10 @@ import type { SlackMessage, SlackThreadMessage, } from './types'; -import { postRouterDebugMessage } from './router-debug'; +import { + postRouterDebugMessage, + postRouterFallbackDebugMessage, +} from './router-debug'; import { postSlackInteractiveResponse } from './interactive-response'; import { getPromptReadyThreadMessages } from './prompt-ready-thread-messages'; import { SlackNotifier } from './slack-notifier'; @@ -784,6 +787,15 @@ function postSlackFinalRouterDebug({ }); } +/** + * Warning shown above the manual workspace picker when automatic routing was + * skipped because the routing infrastructure failed (not because the request + * was ambiguous). Deliberately generic: raw provider errors can contain + * key identifiers and belong in the router debug channel, not user threads. + */ +export const SLACK_ROUTING_UNAVAILABLE_NOTICE = + '⚠️ Automatic routing is temporarily unavailable, so I could not pick a workspace for this request. Choose one below. If this keeps happening, ask an admin to check the deployment’s inference provider.'; + export async function showTaskConfiguration({ event, slackInstallation, @@ -793,6 +805,7 @@ export async function showTaskConfiguration({ skipMcpSetupSuggestion, replaceMessageTs, processingReactionName, + routingFailureNoticeText, }: { event: SlackEvent; slackInstallation: SlackInstallation; @@ -803,6 +816,11 @@ export async function showTaskConfiguration({ /** When provided, update this message instead of posting a new one. */ replaceMessageTs?: string; processingReactionName?: string; + /** + * Warning to show above the manual picker when the caller already attempted + * routing and it failed for infrastructure reasons (used with skipRouting). + */ + routingFailureNoticeText?: string; }): Promise<{ routingUsed: boolean; threadId: string; @@ -888,6 +906,8 @@ export async function showTaskConfiguration({ // Variable to hold routing result for pre-filling the selection UI let routingResult: RoutingResult | null = null; + let routingFallbackNoticeText: string | undefined = + routingFailureNoticeText; let suggestedRoutingDurationMs: number | undefined; let routingThreadMessages: SlackThreadMessage[] | undefined; let latestOwnBotReply: @@ -1040,11 +1060,44 @@ export async function showTaskConfiguration({ console.log( `[LLM Router] Slack routing returned fallback, using default selections: ${decision.reason}`, ); + + if (decision.cause === 'exception') { + routingFallbackNoticeText = SLACK_ROUTING_UNAVAILABLE_NOTICE; + } + + void postRouterFallbackDebugMessage({ + source: `Slack ${event.channel}`, + sourceLink: + buildSlackThreadPermalink({ + slackWorkspaceDomain: slackInstallation.teamDomain ?? undefined, + slackChannelId: event.channel, + threadTs: threadId, + }) ?? undefined, + taskDescription: routingTaskDescription, + reason: decision.reason, + cause: decision.cause, + routingDurationMs: suggestedRoutingDurationMs, + }); } } catch (error) { console.error( `[LLM Router] Error during Slack routing, using default selections: ${error instanceof Error ? error.message : String(error)}`, ); + + routingFallbackNoticeText = SLACK_ROUTING_UNAVAILABLE_NOTICE; + + void postRouterFallbackDebugMessage({ + source: `Slack ${event.channel}`, + sourceLink: + buildSlackThreadPermalink({ + slackWorkspaceDomain: slackInstallation.teamDomain ?? undefined, + slackChannelId: event.channel, + threadTs: threadId, + }) ?? undefined, + taskDescription, + reason: error instanceof Error ? error.message : String(error), + cause: 'exception', + }); } } @@ -1202,14 +1255,15 @@ export async function showTaskConfiguration({ // Routing failed or was skipped - show the manual selection UI as fallback - const blocks: SlackBlock[] = warningText?.trim() - ? [ - { - type: 'section', - text: { type: 'mrkdwn', text: warningText.trim() }, - }, - ] - : []; + const blocks: SlackBlock[] = [ + warningText?.trim(), + routingFallbackNoticeText?.trim(), + ] + .filter((text): text is string => Boolean(text)) + .map((text) => ({ + type: 'section', + text: { type: 'mrkdwn', text }, + })); // Build workspace options with Environments first, then Repositories const workspaceOptions: Array<{ diff --git a/packages/slack/src/router-debug.ts b/packages/slack/src/router-debug.ts index ff2aaf26b..8837fa6f4 100644 --- a/packages/slack/src/router-debug.ts +++ b/packages/slack/src/router-debug.ts @@ -11,7 +11,10 @@ import { slackInstallations, teamsInstallations, } from '@roomote/db/server'; -import type { RoutingDebugInfo } from '@roomote/cloud-agents/server'; +import type { + RoutingDebugInfo, + RoutingFallbackCause, +} from '@roomote/cloud-agents/server'; import { DiscordCommunicationProvider } from '@roomote/communication/discord-provider'; import { TelegramCommunicationProvider } from '@roomote/communication/telegram-provider'; import { createTeamsCommunicationProviderFromEnv } from '@roomote/communication/teams-provider'; @@ -292,6 +295,141 @@ export async function postRouterDebugText(text: string): Promise { } } +export interface RouterFallbackDebugParams { + source: string; + sourceLink?: string; + taskDescription: string; + /** Raw failure reason. Only posted to the internal debug destination. */ + reason: string; + cause?: RoutingFallbackCause; + routingDurationMs?: number; +} + +function formatFallbackCause(cause: RoutingFallbackCause | undefined): string { + return cause === 'exception' + ? 'routing call failed (infrastructure error)' + : 'router declined to pick a workspace'; +} + +/** + * Posts a routing-fallback diagnostic to the configured router debug + * destination. Unlike `postRouterDebugMessage`, this fires at fallback time so + * outages are visible even when no task ends up starting. + */ +export async function postRouterFallbackDebugMessage( + params: RouterFallbackDebugParams, +): Promise { + const destination = await getConfiguredRouterDebugDestination(); + + if (!destination) { + return; + } + + const causeText = formatFallbackCause(params.cause); + const task = truncate(params.taskDescription, 500) || '(empty)'; + const reason = truncate(params.reason, 2500) || '(none)'; + const durationText = + params.routingDurationMs != null ? `${params.routingDurationMs}ms` : null; + + if (destination.provider !== 'slack') { + const source = params.sourceLink + ? `[${params.source}](${params.sourceLink})` + : params.source; + const text = [ + 'Router diagnostics', + `Source: ${source}`, + `⚠️ Routing fallback: no route was chosen, so the manual workspace picker was shown.`, + `Cause: ${causeText}`, + `Message:\n${task}`, + `Failure reason:\n${reason}`, + durationText ? `Duration: ${durationText}` : null, + ] + .filter(Boolean) + .join('\n\n'); + + try { + await postNonSlackRouterDebugMessage({ + provider: destination.provider, + channelId: destination.channelId, + text, + }); + } catch (error) { + console.error( + `[RouterDebug] Failed to post router fallback debug message: ${error instanceof Error ? error.message : String(error)}`, + ); + } + return; + } + + try { + const botAccessToken = await getActiveSlackBotToken(); + + if (!botAccessToken) { + console.warn('[RouterDebug] No active Slack installation found'); + return; + } + + const sourceText = params.sourceLink + ? `<${params.sourceLink}|${params.source}>` + : params.source; + + const blocks: RouterDebugBlocks = [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: `🔍 *Router* | ${sourceText}`, + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: `⚠️ *Routing fallback* — no route was chosen, so the manual workspace picker was shown.\n• *Cause:* ${causeText}`, + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: `*Message*\n${quote(task)}`, + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: `*Failure reason*\n${quote(reason)}`, + }, + }, + ]; + + if (durationText) { + blocks.push({ + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: `⏱️ ${durationText}`, + }, + ], + }); + } + + await createSlackWebClient(botAccessToken).chat.postMessage({ + channel: destination.channelId, + text: `Router fallback | ${params.source}`, + unfurl_links: false, + unfurl_media: false, + blocks, + }); + } catch (error) { + console.error( + `[RouterDebug] Failed to post router fallback debug message: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + export async function postRouterDebugMessage( params: RouterDebugParams, ): Promise { diff --git a/packages/slack/src/start-auto-routed-slack-task.ts b/packages/slack/src/start-auto-routed-slack-task.ts index 413c3a108..2c8cd8f6c 100644 --- a/packages/slack/src/start-auto-routed-slack-task.ts +++ b/packages/slack/src/start-auto-routed-slack-task.ts @@ -2,6 +2,7 @@ import { Env } from '@roomote/env'; import type { SlackInstallation } from '@roomote/db/server'; import { AGENT_DISPLAY_NAME, + buildSlackThreadPermalink, type ChannelAutoStartLaunchMode, DEFAULT_CHANNEL_AUTO_START_LAUNCH_MODE, getTaskInitiatorLinkedUserId, @@ -24,6 +25,7 @@ import { extractPromptTextAttachments, getTaskUrl, routeTask, + type RoutingFallbackCause, type RoutingResult, type SlackMcpSetupRequirement, } from '@roomote/cloud-agents/server'; @@ -33,6 +35,7 @@ import { mapRoutingWorkspaceToSelectionValue, resolveWorkspace, } from './block-kit'; +import { postRouterFallbackDebugMessage } from './router-debug'; import { postSlackMcpSetupSuggestion } from './mcp-setup-suggestion'; import { finishRoutedStart } from './started-message'; import { SlackNotifier } from './slack-notifier'; @@ -77,6 +80,12 @@ export type StartAutoRoutedSlackTaskResult = threadId: string; message: string; routingResult?: RoutingResult; + /** + * Present when the router returned a fallback. `cause: 'exception'` + * means routing infrastructure failed and the manual picker should + * carry a user-visible warning. + */ + routingFallback?: { cause?: RoutingFallbackCause; reason: string }; }; function getNextSlackTimestamp(ts: string): string { @@ -112,12 +121,14 @@ function getLatestSlackTimestamp( function buildRoutingFallbackRequiresPickerResult( threadId: string, + routingFallback?: { cause?: RoutingFallbackCause; reason: string }, ): StartAutoRoutedSlackTaskResult { return { status: 'not_started', code: 'routing_fallback', threadId, message: 'Slack auto-routing needs manual environment selection.', + ...(routingFallback ? { routingFallback } : {}), }; } @@ -382,7 +393,24 @@ export async function startAutoRoutedSlackTask({ } if (decision.status !== 'routed') { - return buildRoutingFallbackRequiresPickerResult(threadId); + void postRouterFallbackDebugMessage({ + source: `Slack ${channel}`, + sourceLink: threadId + ? (buildSlackThreadPermalink({ + slackWorkspaceDomain: slackInstallation.teamDomain ?? undefined, + slackChannelId: channel, + threadTs: threadId, + }) ?? undefined) + : undefined, + taskDescription: taskDescriptionWithAttachments, + reason: decision.reason, + cause: decision.cause, + }); + + return buildRoutingFallbackRequiresPickerResult(threadId, { + cause: decision.cause, + reason: decision.reason, + }); } const workspaceValue = mapRoutingWorkspaceToSelectionValue( From 077ca822efaebea65a7fdeb3a76a5f51664c95e9 Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:12:39 -0400 Subject: [PATCH 2/3] Remove warning emoji from routing fallback notices --- .../api/src/handlers/slack/events/auto-route-fallback.test.ts | 4 ++-- packages/slack/src/block-kit.ts | 2 +- packages/slack/src/router-debug.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/api/src/handlers/slack/events/auto-route-fallback.test.ts b/apps/api/src/handlers/slack/events/auto-route-fallback.test.ts index 06a8feafc..a624affa4 100644 --- a/apps/api/src/handlers/slack/events/auto-route-fallback.test.ts +++ b/apps/api/src/handlers/slack/events/auto-route-fallback.test.ts @@ -4,7 +4,7 @@ const { showTaskConfigurationMock } = vi.hoisted(() => ({ vi.mock('@roomote/slack', () => ({ showTaskConfiguration: showTaskConfigurationMock, - SLACK_ROUTING_UNAVAILABLE_NOTICE: '⚠️ routing unavailable notice', + SLACK_ROUTING_UNAVAILABLE_NOTICE: 'routing unavailable notice', })); describe('auto-route-fallback', () => { @@ -91,7 +91,7 @@ describe('auto-route-fallback', () => { expect(showTaskConfigurationMock).toHaveBeenCalledWith( expect.objectContaining({ skipRouting: true, - routingFailureNoticeText: '⚠️ routing unavailable notice', + routingFailureNoticeText: 'routing unavailable notice', }), ); }); diff --git a/packages/slack/src/block-kit.ts b/packages/slack/src/block-kit.ts index 9aecd3c49..2b7f504c3 100644 --- a/packages/slack/src/block-kit.ts +++ b/packages/slack/src/block-kit.ts @@ -794,7 +794,7 @@ function postSlackFinalRouterDebug({ * key identifiers and belong in the router debug channel, not user threads. */ export const SLACK_ROUTING_UNAVAILABLE_NOTICE = - '⚠️ Automatic routing is temporarily unavailable, so I could not pick a workspace for this request. Choose one below. If this keeps happening, ask an admin to check the deployment’s inference provider.'; + 'Automatic routing is temporarily unavailable, so I could not pick a workspace for this request. Choose one below. If this keeps happening, ask an admin to check the deployment’s inference provider.'; export async function showTaskConfiguration({ event, diff --git a/packages/slack/src/router-debug.ts b/packages/slack/src/router-debug.ts index 8837fa6f4..fd2bcd915 100644 --- a/packages/slack/src/router-debug.ts +++ b/packages/slack/src/router-debug.ts @@ -338,7 +338,7 @@ export async function postRouterFallbackDebugMessage( const text = [ 'Router diagnostics', `Source: ${source}`, - `⚠️ Routing fallback: no route was chosen, so the manual workspace picker was shown.`, + `Routing fallback: no route was chosen, so the manual workspace picker was shown.`, `Cause: ${causeText}`, `Message:\n${task}`, `Failure reason:\n${reason}`, @@ -385,7 +385,7 @@ export async function postRouterFallbackDebugMessage( type: 'section', text: { type: 'mrkdwn', - text: `⚠️ *Routing fallback* — no route was chosen, so the manual workspace picker was shown.\n• *Cause:* ${causeText}`, + text: `*Routing fallback* — no route was chosen, so the manual workspace picker was shown.\n• *Cause:* ${causeText}`, }, }, { From f548e72d79e58323346dcbbdc57fb8219691f466 Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:24:07 -0400 Subject: [PATCH 3/3] Scope exception fallback to the routeTask call itself Slack context setup failures (thread fetch, channel lookup) in the outer catch no longer claim the routing infrastructure is down; only a thrown router invocation maps to cause 'exception'. --- .../__tests__/show-task-configuration.test.ts | 62 +++++++++++++++++++ packages/slack/src/block-kit.ts | 31 +++++----- 2 files changed, 76 insertions(+), 17 deletions(-) diff --git a/packages/slack/src/__tests__/show-task-configuration.test.ts b/packages/slack/src/__tests__/show-task-configuration.test.ts index 39ec42317..c9f40e81f 100644 --- a/packages/slack/src/__tests__/show-task-configuration.test.ts +++ b/packages/slack/src/__tests__/show-task-configuration.test.ts @@ -706,6 +706,68 @@ describe('Slack deleted-mention suppression', () => { ); }); + it('treats a thrown routeTask error as an exception fallback with a warning', async () => { + routeTaskMock.mockRejectedValueOnce(new Error('router transport failed')); + const slack = new SlackNotifier('xoxb-test'); + + await showTaskConfiguration({ + event: { + type: 'app_mention', + channel: 'C123', + user: 'U123', + text: '<@BOT> investigate this', + ts: '111.222', + }, + slackInstallation: { + teamId: 'T123', + } as never, + userMapping: { + userId: 'user_1', + } as never, + slack: slack as never, + }); + + expect(postRouterFallbackDebugMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ + reason: 'router transport failed', + cause: 'exception', + }), + ); + expect(JSON.stringify(postMessageMock.mock.calls)).toContain( + SLACK_ROUTING_UNAVAILABLE_NOTICE, + ); + }); + + it('does not blame the routing infrastructure when Slack context setup fails', async () => { + getChannelNameMock.mockRejectedValueOnce( + new Error('channels.info unavailable'), + ); + const slack = new SlackNotifier('xoxb-test'); + + await showTaskConfiguration({ + event: { + type: 'app_mention', + channel: 'C123', + user: 'U123', + text: '<@BOT> investigate this', + ts: '111.222', + }, + slackInstallation: { + teamId: 'T123', + } as never, + userMapping: { + userId: 'user_1', + } as never, + slack: slack as never, + }); + + expect(routeTaskMock).not.toHaveBeenCalled(); + expect(postRouterFallbackDebugMessageMock).not.toHaveBeenCalled(); + expect(JSON.stringify(postMessageMock.mock.calls)).not.toContain( + SLACK_ROUTING_UNAVAILABLE_NOTICE, + ); + }); + it('shows the plain picker without a warning when the router declined to pick', async () => { routeTaskMock.mockResolvedValueOnce({ status: 'fallback', diff --git a/packages/slack/src/block-kit.ts b/packages/slack/src/block-kit.ts index 2b7f504c3..2504974f1 100644 --- a/packages/slack/src/block-kit.ts +++ b/packages/slack/src/block-kit.ts @@ -997,9 +997,21 @@ export async function showTaskConfiguration({ `[LLM Router] Attempting to route Slack task (channel: ${event.channel}, envs: ${routingContext.availableEnvironments.length})`, ); - // Attempt LLM routing - result is used to pre-fill the selection UI + // Attempt LLM routing - result is used to pre-fill the selection UI. + // Only the router invocation itself maps to an exception fallback; + // surrounding Slack context setup failures are handled by the outer + // catch and must not claim the routing infrastructure is down. const routingStart = Date.now(); - const decision = await routeTask(routingContext); + let decision: Awaited>; + try { + decision = await routeTask(routingContext); + } catch (error) { + decision = { + status: 'fallback', + reason: error instanceof Error ? error.message : String(error), + cause: 'exception', + }; + } suggestedRoutingDurationMs = Date.now() - routingStart; if (decision.status === 'platform_answer') { @@ -1083,21 +1095,6 @@ export async function showTaskConfiguration({ console.error( `[LLM Router] Error during Slack routing, using default selections: ${error instanceof Error ? error.message : String(error)}`, ); - - routingFallbackNoticeText = SLACK_ROUTING_UNAVAILABLE_NOTICE; - - void postRouterFallbackDebugMessage({ - source: `Slack ${event.channel}`, - sourceLink: - buildSlackThreadPermalink({ - slackWorkspaceDomain: slackInstallation.teamDomain ?? undefined, - slackChannelId: event.channel, - threadTs: threadId, - }) ?? undefined, - taskDescription, - reason: error instanceof Error ? error.message : String(error), - cause: 'exception', - }); } }