From 5fcdc6eae4c1ead970b2c560f7debc0601226ee0 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 09:00:04 +0000 Subject: [PATCH] feat: add graceful timeout and early session links for Slack/Discord webhooks Add a 750s internal timeout (below Vercel's 800s maxDuration) to both the Slack and Discord webhook handlers so users get a friendly message instead of a silent failure when Cloud Agent sessions run long. When the timeout fires, post a message explaining the session is still running and swap the processing reaction to complete. Also send the Cloud Agent session link as soon as the session is created (not after it finishes). For Slack this is an ephemeral message with a View Session button; for Discord it's a regular reply with the session URL. --- src/app/discord/webhook/route.ts | 129 ++++++++++++++++++++++++++++++- src/app/slack/webhook/route.ts | 90 +++++++++++++++++---- src/lib/discord-bot.ts | 5 +- src/lib/slack-bot.ts | 7 +- 4 files changed, 213 insertions(+), 18 deletions(-) diff --git a/src/app/discord/webhook/route.ts b/src/app/discord/webhook/route.ts index 7b80b678f..dc122d0bf 100644 --- a/src/app/discord/webhook/route.ts +++ b/src/app/discord/webhook/route.ts @@ -9,6 +9,8 @@ import { postDiscordMessage, addDiscordReaction, removeDiscordReaction, + getInstallationByGuildId, + getOwnerFromInstallation, } from '@/lib/integrations/discord-service'; import { stripDiscordBotMention, @@ -17,15 +19,89 @@ import { truncateForDiscord, } from '@/lib/discord-bot/discord-utils'; import { getDevUserSuffix } from '@/lib/slack-bot/dev-user-info'; +import { APP_URL } from '@/lib/constants'; +import { db } from '@/lib/drizzle'; +import { cli_sessions_v2 } from '@kilocode/db/schema'; +import { eq } from 'drizzle-orm'; + +import type { Owner } from '@/lib/integrations/core/types'; export const maxDuration = 800; +/** + * Internal timeout (ms) — must be lower than Vercel's maxDuration so we can + * send a graceful message before the function is killed. + */ +const ENDPOINT_TIMEOUT_MS = 750 * 1000; + /** * Reaction emoji for processing state */ const PROCESSING_EMOJI = '\u23f3'; // hourglass const COMPLETE_EMOJI = '\u2705'; // white check mark +/** + * Build the session URL for a cloud agent session based on the owner type + */ +function buildSessionUrl(dbSessionId: string, owner: Owner): string { + const basePath = owner.type === 'org' ? `/organizations/${owner.id}/cloud` : '/cloud'; + return `${APP_URL}${basePath}/chat?sessionId=${dbSessionId}`; +} + +/** + * Look up the database session UUID from the cloud agent session ID + */ +async function getDbSessionIdFromCloudAgentId(cloudAgentSessionId: string): Promise { + const [session] = await db + .select({ session_id: cli_sessions_v2.session_id }) + .from(cli_sessions_v2) + .where(eq(cli_sessions_v2.cloud_agent_session_id, cloudAgentSessionId)) + .limit(1); + + return session?.session_id ?? null; +} + +/** + * Post a message with a link to the Cloud Agent session + */ +async function postSessionLinkMessage({ + channelId, + messageId, + cloudAgentSessionId, + guildId, +}: { + channelId: string; + messageId: string; + cloudAgentSessionId: string; + guildId: string; +}): Promise { + const installation = await getInstallationByGuildId(guildId); + if (!installation) { + console.error('[DiscordBot:Webhook] Could not find installation for session link'); + return; + } + + const owner = getOwnerFromInstallation(installation); + if (!owner) { + console.error('[DiscordBot:Webhook] Could not determine owner for session link'); + return; + } + + const dbSessionId = await getDbSessionIdFromCloudAgentId(cloudAgentSessionId); + if (!dbSessionId) { + console.error( + '[DiscordBot:Webhook] Could not find database session for cloud agent session:', + cloudAgentSessionId + ); + return; + } + + const sessionUrl = buildSessionUrl(dbSessionId, owner); + await postDiscordMessage(channelId, `Cloud Agent session started — follow along here: ${sessionUrl}`, { + messageReference: { message_id: messageId }, + }); +} + /** * Forwarded Gateway event shape (from the Gateway listener) */ @@ -155,14 +231,63 @@ async function processGatewayMessage(event: ForwardedGatewayEvent) { const startTime = Date.now(); - // Process through bot - const result = await processDiscordBotMessage(resolvedText, guildId, { + // Post a session link as soon as the Cloud Agent session is created + let sessionLinkSent = false; + const onCloudAgentSessionCreated = (cloudAgentSessionId: string) => { + if (sessionLinkSent) return; + sessionLinkSent = true; + postSessionLinkMessage({ + channelId, + messageId, + cloudAgentSessionId, + guildId, + }).catch(err => { + console.error('[DiscordBot:Webhook] Failed to send session link:', err); + }); + }; + + // Process through bot, with an internal timeout that fires before Vercel's + // maxDuration so we can send a graceful message to the user. + const botPromise = processDiscordBotMessage(resolvedText, guildId, { channelId, guildId, userId: author.id, messageId, + onCloudAgentSessionCreated, }); + const timeoutPromise = new Promise(resolve => { + setTimeout(() => resolve(null), ENDPOINT_TIMEOUT_MS); + }); + + const result = await Promise.race([botPromise, timeoutPromise]); + + if (result === null) { + // Internal timeout fired before the bot finished + console.log('[DiscordBot:Webhook] Internal endpoint timeout reached'); + + const timeoutMessage = truncateForDiscord( + 'The Cloud Agent session is taking longer than expected. ' + + 'Our endpoint is shutting down, but the session is still running. ' + + 'Check the session link above to follow along.' + ); + await postDiscordMessage(channelId, timeoutMessage, { + messageReference: { message_id: messageId }, + }); + + // Swap reactions to indicate we're done (from our side) + const [removeResult] = await Promise.all([ + removeDiscordReaction(channelId, messageId, PROCESSING_EMOJI), + addDiscordReaction(channelId, messageId, COMPLETE_EMOJI), + ]); + if (!removeResult.ok) { + await removeDiscordReaction(channelId, messageId, PROCESSING_EMOJI); + } + + console.log('[DiscordBot:Webhook] processGatewayMessage timed out gracefully'); + return; + } + const responseTimeMs = Date.now() - startTime; console.log(`[DiscordBot:Webhook] Bot processing completed in ${responseTimeMs}ms`); diff --git a/src/app/slack/webhook/route.ts b/src/app/slack/webhook/route.ts index 311d1ce73..20f847629 100644 --- a/src/app/slack/webhook/route.ts +++ b/src/app/slack/webhook/route.ts @@ -31,6 +31,12 @@ import type { PlatformIntegration } from '@kilocode/db/schema'; export const maxDuration = 800; +/** + * Internal timeout (ms) — must be lower than Vercel's maxDuration so we can + * send a graceful message before the function is killed. + */ +const ENDPOINT_TIMEOUT_MS = 750 * 1000; + /** * Reaction emoji names */ @@ -228,13 +234,81 @@ async function processSlackMessage(event: AppMentionEvent | GenericMessageEvent, name: PROCESSING_REACTION, }); - // Process the message through Kilo Bot - const result = await processKiloBotMessage(kiloInputText, teamId, { + // Send an ephemeral session link as soon as the Cloud Agent session is created + let sessionLinkSent = false; + const onCloudAgentSessionCreated = (cloudAgentSessionId: string) => { + if (sessionLinkSent || !user) return; + sessionLinkSent = true; + postSessionLinkEphemeral({ + accessToken, + channel, + user, + threadTs: replyThreadTs, + cloudAgentSessionId, + installation, + }).catch(err => { + console.error('[SlackBot:Webhook] Failed to send session link ephemeral:', err); + }); + }; + + // Process the message through Kilo Bot, with an internal timeout that fires + // before Vercel's maxDuration so we can send a graceful message to the user. + const botPromise = processKiloBotMessage(kiloInputText, teamId, { channelId: channel, threadTs: replyThreadTs, userId: user as string, messageTs: ts, + onCloudAgentSessionCreated, + }); + + const timeoutPromise = new Promise(resolve => { + setTimeout(() => resolve(null), ENDPOINT_TIMEOUT_MS); }); + + const result = await Promise.race([botPromise, timeoutPromise]); + + if (result === null) { + // Internal timeout fired before the bot finished + console.log('[SlackBot:Webhook] Internal endpoint timeout reached'); + + const timeoutMessage = markdownToSlackMrkdwn( + 'The Cloud Agent session is taking longer than expected. ' + + 'Our endpoint is shutting down, but the session is still running. ' + + 'Check the session link above to follow along.' + ); + await postSlackMessageByAccessToken(accessToken, { + channel, + text: timeoutMessage, + thread_ts: replyThreadTs, + blocks: [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: timeoutMessage, + }, + }, + ], + }); + + // Swap reactions to indicate we're done (from our side) + await Promise.all([ + removeSlackReactionByAccessToken(accessToken, { + channel, + timestamp: ts, + name: PROCESSING_REACTION, + }), + addSlackReactionByAccessToken(accessToken, { + channel, + timestamp: ts, + name: COMPLETE_REACTION, + }), + ]); + + console.log(`[SlackBot:Webhook] processSlackMessage (${event.type}) timed out gracefully`); + return; + } + const responseTimeMs = Date.now() - startTime; // Append dev user suffix if in dev environment @@ -276,18 +350,6 @@ async function processSlackMessage(event: AppMentionEvent | GenericMessageEvent, }), ]); - // If a cloud agent session was created, post an ephemeral button for the user to view it - if (result.cloudAgentSessionId && user) { - await postSessionLinkEphemeral({ - accessToken, - channel, - user, - threadTs: replyThreadTs, - cloudAgentSessionId: result.cloudAgentSessionId, - installation, - }); - } - // Log the request for admin debugging await logSlackBotRequest({ slackTeamId: teamId, diff --git a/src/lib/discord-bot.ts b/src/lib/discord-bot.ts index b16efaf7b..3dba6d832 100644 --- a/src/lib/discord-bot.ts +++ b/src/lib/discord-bot.ts @@ -214,7 +214,9 @@ async function spawnCloudAgentSession( export async function processDiscordBotMessage( userMessage: string, guildId: string, - discordEventContext?: DiscordEventContext + discordEventContext?: DiscordEventContext & { + onCloudAgentSessionCreated?: (cloudAgentSessionId: string) => void; + } ): Promise { console.log('[DiscordBot] processDiscordBotMessage started'); @@ -334,6 +336,7 @@ export async function processDiscordBotMessage( if (toolResult.sessionId) { cloudAgentSessionId = toolResult.sessionId; + discordEventContext?.onCloudAgentSessionCreated?.(toolResult.sessionId); } return { content: toolResult.response }; diff --git a/src/lib/slack-bot.ts b/src/lib/slack-bot.ts index 1743cfc86..20345a4b8 100644 --- a/src/lib/slack-bot.ts +++ b/src/lib/slack-bot.ts @@ -375,11 +375,15 @@ async function spawnCloudAgentSession( * This is the main entry point for generating AI responses with tool support. * @param userMessage The message from the user * @param teamId The Slack team ID to identify which integration to use + * @param slackEventContext Optional Slack event context with channel/thread info + * and an optional callback fired when a Cloud Agent session is created. */ export async function processKiloBotMessage( userMessage: string, teamId: string, - slackEventContext?: SlackEventContext + slackEventContext?: SlackEventContext & { + onCloudAgentSessionCreated?: (cloudAgentSessionId: string) => void; + } ): Promise { console.log('[SlackBot] processKiloBotMessage started with message:', userMessage); console.log('[SlackBot] Looking up Slack integration for team:', teamId); @@ -537,6 +541,7 @@ export async function processKiloBotMessage( console.log('[SlackBot] Tool result preview:', toolResult.response.slice(0, 100)); if (toolResult.sessionId) { cloudAgentSessionId = toolResult.sessionId; + slackEventContext?.onCloudAgentSessionCreated?.(toolResult.sessionId); } return {