-
Notifications
You must be signed in to change notification settings - Fork 8
feat: add graceful timeout and early session links for Slack/Discord webhooks #755
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<string | null> { | ||
| 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<void> { | ||
| 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<null>(resolve => { | ||
| setTimeout(() => resolve(null), ENDPOINT_TIMEOUT_MS); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [SUGGESTION]: The When let timeoutId: ReturnType<typeof setTimeout>;
const timeoutPromise = new Promise<null>(resolve => {
timeoutId = setTimeout(() => resolve(null), ENDPOINT_TIMEOUT_MS);
});
const result = await Promise.race([botPromise, timeoutPromise]);
clearTimeout(timeoutId!);Same applies to the Slack handler at |
||
| }); | ||
|
|
||
| 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.' | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [WARNING]: Race condition — session link may not be posted yet when timeout message references it The If the timeout fires shortly after the session is created, this message says "Check the session link above to follow along" but the link message may not have been posted yet. The user would see the timeout message first, then the link message second — or the link message might fail entirely, leaving the user with no way to follow along. Consider either:
|
||
| ); | ||
| 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`); | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<null>(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.' | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [WARNING]: Same race condition as Discord — the ephemeral session link may not have been delivered yet when this message references it The Same mitigation options as the Discord side: await the pending link promise before posting the timeout message, or adjust the wording. |
||
| ); | ||
| 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; | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [WARNING]: The early Consider adding a |
||
| } | ||
|
|
||
| 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, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[SUGGESTION]:
buildSessionUrlandgetDbSessionIdFromCloudAgentIdare now duplicated identically between the Discord and Slack webhook routesBoth functions have the exact same implementation. Consider extracting them into a shared module (e.g.
src/lib/session-url.tsor similar) to keep them in sync.