Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 127 additions & 2 deletions src/app/discord/webhook/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
postDiscordMessage,
addDiscordReaction,
removeDiscordReaction,
getInstallationByGuildId,
getOwnerFromInstallation,
} from '@/lib/integrations/discord-service';
import {
stripDiscordBotMention,
Expand All @@ -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 {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION]: buildSessionUrl and getDbSessionIdFromCloudAgentId are now duplicated identically between the Discord and Slack webhook routes

Both functions have the exact same implementation. Consider extracting them into a shared module (e.g. src/lib/session-url.ts or similar) to keep them in sync.

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)
*/
Expand Down Expand Up @@ -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);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION]: The setTimeout timer is never cleared on the normal (non-timeout) path

When botPromise resolves before the timeout, the 750-second timer continues running in the background. On a serverless platform this is unlikely to cause real issues (the function will be killed), but it's good hygiene to clear it:

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 src/app/slack/webhook/route.ts:265.

});

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.'
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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 onCloudAgentSessionCreated callback fires postSessionLinkMessage as a fire-and-forget promise. That function performs 3 async operations (getInstallationByGuildId, getDbSessionIdFromCloudAgentId, postDiscordMessage) before the link actually appears in the channel.

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:

  • awaiting the session link promise before posting the timeout message (if sessionLinkSent is true)
  • Changing the wording to not assume the link is "above"

);
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`);

Expand Down
90 changes: 76 additions & 14 deletions src/app/slack/webhook/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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.'
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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 onCloudAgentSessionCreated callback fires postSessionLinkEphemeral as fire-and-forget. If the timeout fires shortly after session creation, the user sees "Check the session link above" before the ephemeral link is actually posted.

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;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING]: logSlackBotRequest is skipped on the timeout path

The early return here means timed-out requests are never logged via logSlackBotRequest (which runs at line 354). This creates a blind spot in admin debugging — the longest-running requests (the ones most likely to need investigation) won't appear in the logs.

Consider adding a logSlackBotRequest call with status: 'timeout' before returning.

}

const responseTimeMs = Date.now() - startTime;

// Append dev user suffix if in dev environment
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion src/lib/discord-bot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,9 @@ async function spawnCloudAgentSession(
export async function processDiscordBotMessage(
userMessage: string,
guildId: string,
discordEventContext?: DiscordEventContext
discordEventContext?: DiscordEventContext & {
onCloudAgentSessionCreated?: (cloudAgentSessionId: string) => void;
}
): Promise<DiscordBotMessageResult> {
console.log('[DiscordBot] processDiscordBotMessage started');

Expand Down Expand Up @@ -334,6 +336,7 @@ export async function processDiscordBotMessage(

if (toolResult.sessionId) {
cloudAgentSessionId = toolResult.sessionId;
discordEventContext?.onCloudAgentSessionCreated?.(toolResult.sessionId);
}

return { content: toolResult.response };
Expand Down
7 changes: 6 additions & 1 deletion src/lib/slack-bot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<KiloBotMessageResult> {
console.log('[SlackBot] processKiloBotMessage started with message:', userMessage);
console.log('[SlackBot] Looking up Slack integration for team:', teamId);
Expand Down Expand Up @@ -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 {
Expand Down
Loading