diff --git a/.env.example b/.env.example index 16953be..cb07bf6 100644 --- a/.env.example +++ b/.env.example @@ -49,6 +49,13 @@ WEBHOOK_PORT=4847 # The webhook signing secret from your Linear OAuth app LINEAR_WEBHOOK_SECRET=your-webhook-signing-secret +# Webhook Checkbox Debounce Configuration (optional) +# Controls how long to wait after checkbox changes before triggering consolidation +# Formula: debounceMs = baseMs + (questionCount * perQuestionMs), capped at maxMs +# WEBHOOK_CHECKBOX_DEBOUNCE_BASE_MS=3000 # Base timeout: 3 seconds +# WEBHOOK_CHECKBOX_DEBOUNCE_PER_QUESTION_MS=1000 # Add 1 second per question +# WEBHOOK_CHECKBOX_DEBOUNCE_MAX_MS=30000 # Max timeout: 30 seconds + # ngrok API key - used by setup script to list your available domains # Get it from: https://dashboard.ngrok.com/api # NGROK_API_KEY=your-ngrok-api-key diff --git a/src/config.ts b/src/config.ts index 6f8f33b..689112a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -34,6 +34,11 @@ const ConfigSchema = z.object({ port: z.number().int().min(1).max(65535).default(4847), allowUnsigned: z.boolean().default(false), // Only for development - allows unsigned webhooks ngrokDomain: z.string().optional(), // Custom ngrok domain (e.g., "yan-od.ngrok.dev") - if set, ngrok is managed externally + checkboxDebounce: z.object({ + baseMs: z.number().int().min(1000).default(3000), // Base timeout: 3 seconds + perQuestionMs: z.number().int().min(0).default(1000), // Add 1 second per question + maxMs: z.number().int().min(1000).default(30000), // Max timeout: 30 seconds + }), }), isDevelopment: z.boolean().default(false), github: z.object({ @@ -104,6 +109,11 @@ function loadConfig(): Config { port: parseInt(process.env['WEBHOOK_PORT'] || '4847', 10), allowUnsigned: process.env['WEBHOOK_ALLOW_UNSIGNED'] === 'true', ngrokDomain: process.env['NGROK_CUSTOM_DOMAIN'] || undefined, + checkboxDebounce: { + baseMs: parseInt(process.env['WEBHOOK_CHECKBOX_DEBOUNCE_BASE_MS'] || '3000', 10), + perQuestionMs: parseInt(process.env['WEBHOOK_CHECKBOX_DEBOUNCE_PER_QUESTION_MS'] || '1000', 10), + maxMs: parseInt(process.env['WEBHOOK_CHECKBOX_DEBOUNCE_MAX_MS'] || '30000', 10), + }, }, isDevelopment: process.env['NODE_ENV'] !== 'production', github: { diff --git a/src/queue/processor.ts b/src/queue/processor.ts index ba09987..63e5f48 100644 --- a/src/queue/processor.ts +++ b/src/queue/processor.ts @@ -1158,7 +1158,9 @@ ${ticket.description || ''}`; await linearClient.addComment( task.ticketId, - '✅ Planning complete! Please answer the questions above, then use `@taskAgent work` to start implementation.' + '✅ Planning questions posted! Please answer the questions above by checking the boxes.\n\n' + + '**When you\'re done answering all questions**, post a comment with `/done` to consolidate the plan.\n\n' + + 'Note: Unanswered questions will be treated as skipped.' ); this.callbacks.onStateChange?.(task.ticketId, 'awaiting_planning_response'); @@ -1255,6 +1257,30 @@ ${ticket.description || ''}`; return; } + // Analyze which questions were answered vs skipped + const planningQuestions = comments.filter( + (c) => isTaskAgentComment(c.user) && c.body.includes('Planning Question') + ); + const questionsWithCheckboxes = planningQuestions.filter( + (c) => c.body.includes('- [') // Has checkbox format + ); + const answeredQuestions = questionsWithCheckboxes.filter( + (c) => c.body.includes('[X]') || c.body.includes('[x]') + ); + const unansweredQuestions = questionsWithCheckboxes.filter( + (c) => !c.body.includes('[X]') && !c.body.includes('[x]') + ); + + logger.info( + { + ticketId: task.ticketIdentifier, + totalQuestions: questionsWithCheckboxes.length, + answeredCount: answeredQuestions.length, + skippedCount: unansweredQuestions.length, + }, + 'Question response analysis complete' + ); + // Update the ticket description with the consolidated plan // Prepend the plan to the original description const updatedDescription = `# Implementation Plan @@ -1269,11 +1295,22 @@ ${ticket.description || ''}`; await linearClient.updateDescription(task.ticketId, updatedDescription); - // Post confirmation comment - await linearClient.addComment( - task.ticketId, - `✅ Planning complete! I've consolidated our discussion into an implementation plan and updated the ticket description.\n\nYou can now use \`@taskAgent work\` to start implementation.` - ); + // Post confirmation comment with question statistics + let confirmationMessage = `✅ Planning complete! I've consolidated our discussion into an implementation plan and updated the ticket description.\n\n`; + + if (questionsWithCheckboxes.length > 0) { + confirmationMessage += `**Question Summary:**\n`; + confirmationMessage += `- Answered: ${answeredQuestions.length}/${questionsWithCheckboxes.length}\n`; + + if (unansweredQuestions.length > 0) { + confirmationMessage += `- Skipped: ${unansweredQuestions.length}\n\n`; + confirmationMessage += `Note: Unanswered questions were treated as skipped and the plan proceeds without those answers.\n\n`; + } + } + + confirmationMessage += `You can now use \`@taskAgent work\` to start implementation.`; + + await linearClient.addComment(task.ticketId, confirmationMessage); // Clear awaiting response state queueScheduler.clearAwaitingResponse(task.ticketId); diff --git a/src/webhook/handler.ts b/src/webhook/handler.ts index 269580c..b81c950 100644 --- a/src/webhook/handler.ts +++ b/src/webhook/handler.ts @@ -32,14 +32,69 @@ function isCommentFromTaskAgent(userId?: string): boolean { // Using 4 seconds gives us a 1 second buffer const WEBHOOK_TIMEOUT_MS = 4000; -// Debounce delay for checkbox changes - wait this long after the last checkbox change -// before triggering re-evaluation. This gives users time to answer multiple questions. -const CHECKBOX_DEBOUNCE_MS = 5 * 1000; // 5 seconds - // Track pending checkbox debounce timers per ticket const checkboxDebounceTimers = new Map(); const lastCheckboxChange = new Map(); +// Track question count per ticket for dynamic debounce calculation +const questionCountCache = new Map(); + +// Track completion signals per ticket +const completionSignalReceived = new Map(); + +/** + * Calculate dynamic debounce timeout based on number of questions + * Formula: baseMs + (questionCount * perQuestionMs), capped at maxMs + */ +function calculateDebounceTimeout(questionCount: number): number { + const { baseMs, perQuestionMs, maxMs } = config.webhook.checkboxDebounce; + const calculated = baseMs + (questionCount * perQuestionMs); + return Math.min(calculated, maxMs); +} + +/** + * Count the number of planning questions in comments + */ +function countPlanningQuestions(issueId: string): number { + const cachedCount = questionCountCache.get(issueId); + if (cachedCount !== undefined) { + return cachedCount; + } + + const comments = linearCache.getComments(issueId); + const count = comments.filter( + (c) => isCommentFromTaskAgent(c.user?.id) && c.body.includes('Planning Question') + ).length; + + questionCountCache.set(issueId, count); + return count; +} + +/** + * Check if a comment contains a completion signal + * Completion signals: /done, "done answering", "finished answering", etc. + */ +function isCompletionSignal(commentBody: string): boolean { + const lowerBody = commentBody.toLowerCase().trim(); + + // Check for explicit completion commands + if (lowerBody === '/done' || lowerBody === 'done') { + return true; + } + + // Check for natural language completion signals + const completionPhrases = [ + 'done answering', + 'finished answering', + 'completed answering', + 'all done', + "i'm done", + 'im done', + ]; + + return completionPhrases.some(phrase => lowerBody.includes(phrase)); +} + /** * Execute a handler with a timeout to ensure we respond to Linear within 5 seconds * If the handler takes too long, we log a warning but don't fail the response @@ -215,35 +270,61 @@ async function handleCommentCreate(data: WebhookCommentData): Promise { const mention = parseMention(data.body); if (!mention.found) { - // Check if this is a response to planning questions - // If the issue is awaiting a 'questions' response and has planning questions, - // trigger plan consolidation - if (queueScheduler.isAwaitingResponse(issueId)) { - const awaitingType = queueScheduler.getAwaitingResponseType(issueId); - if (awaitingType === 'questions') { - // Check if there are planning questions in the cache - const cachedTicket = linearCache.getTicket(issueId); - if (cachedTicket) { + // Check if this is a completion signal for planning questions + if (isCompletionSignal(data.body)) { + logger.info( + { issueId, commentBody: data.body.substring(0, 50) }, + 'Detected completion signal for planning questions' + ); + + // Mark completion signal as received + completionSignalReceived.set(issueId, true); + + // Check if there are planning questions in the cache + const cachedTicket = linearCache.getTicket(issueId); + if (cachedTicket && queueScheduler.isAwaitingResponse(issueId)) { + const awaitingType = queueScheduler.getAwaitingResponseType(issueId); + if (awaitingType === 'questions') { const cachedComments = linearCache.getComments(issueId); const hasPlanningQuestions = cachedComments.some( - (c) => c.body?.includes('Planning Question') + (c) => isCommentFromTaskAgent(c.user?.id) && c.body.includes('Planning Question') ); if (hasPlanningQuestions) { logger.info( { issueId, ticketId: cachedTicket.identifier }, - 'Detected response to planning questions - triggering plan consolidation' + 'Completion signal received - triggering plan consolidation immediately' ); + // Clear any pending debounce timer + const existingTimer = checkboxDebounceTimers.get(issueId); + if (existingTimer) { + clearTimeout(existingTimer); + checkboxDebounceTimers.delete(issueId); + logger.debug({ issueId }, 'Cleared pending debounce timer due to completion signal'); + } + // Clear awaiting state queueScheduler.clearAwaitingResponse(issueId); + // Clean up caches + questionCountCache.delete(issueId); + completionSignalReceived.delete(issueId); + + // Delete the completion signal comment to keep the ticket clean + try { + await linearClient.deleteComment(data.id); + logger.debug({ commentId: data.id }, 'Deleted completion signal comment'); + } catch (error) { + logger.error({ commentId: data.id, error: error instanceof Error ? error.message : error }, 'Failed to delete completion signal comment'); + } + // Enqueue plan consolidation task linearQueue.enqueue({ ticketId: issueId, ticketIdentifier: cachedTicket.identifier, taskType: 'consolidate_plan', - priority: 1, // High priority - human just responded + priority: 1, // High priority - human just signaled completion }); return; @@ -345,8 +426,12 @@ async function handleCommentUpdate(data: WebhookCommentData): Promise { if (hasCheckedBoxes) { const issueId = data.issueId; + // Check if this is for planning questions + const awaitingType = queueScheduler.getAwaitingResponseType(issueId); + const isAwaitingPlanningQuestions = awaitingType === 'questions'; + logger.info( - { issueId, commentId: data.id }, + { issueId, commentId: data.id, isAwaitingPlanningQuestions }, 'User checked checkbox in TaskAgent question - debouncing before re-evaluation' ); @@ -360,11 +445,66 @@ async function handleCommentUpdate(data: WebhookCommentData): Promise { logger.debug({ issueId }, 'Cleared existing checkbox debounce timer'); } + // Calculate dynamic debounce timeout based on question count + let debounceMs: number; + if (isAwaitingPlanningQuestions) { + const questionCount = countPlanningQuestions(issueId); + debounceMs = calculateDebounceTimeout(questionCount); + logger.debug( + { issueId, questionCount, debounceMs }, + 'Using dynamic debounce timeout for planning questions' + ); + } else { + // For regular clarification questions, use base timeout + debounceMs = config.webhook.checkboxDebounce.baseMs; + } + // Set a new debounce timer - only trigger after user stops checking boxes // This gives users time to answer multiple questions before we re-evaluate + // For planning questions, wait for explicit completion signal const timer = setTimeout(() => { checkboxDebounceTimers.delete(issueId); + // For planning questions, check if we received a completion signal + if (isAwaitingPlanningQuestions) { + const hasCompletionSignal = completionSignalReceived.get(issueId); + if (!hasCompletionSignal) { + logger.info( + { issueId }, + 'Checkbox debounce timer fired for planning questions, but no completion signal received - waiting for /done' + ); + // Don't trigger consolidation - wait for explicit completion signal + return; + } + + logger.info( + { issueId }, + 'Checkbox debounce timer fired with completion signal - triggering plan consolidation' + ); + + // Clear awaiting state + queueScheduler.clearAwaitingResponse(issueId); + + // Clean up caches + questionCountCache.delete(issueId); + completionSignalReceived.delete(issueId); + + // Enqueue plan consolidation task + const cachedTicket = linearCache.getTicket(issueId); + const ticketIdentifier = cachedTicket?.identifier || `ticket-${issueId}`; + + linearQueue.enqueue({ + ticketId: issueId, + ticketIdentifier, + taskType: 'consolidate_plan', + priority: 1, // High priority - human just completed answering + }); + + logger.info({ issueId, ticketIdentifier }, 'Enqueued plan consolidation from checkbox update (after debounce + completion signal)'); + return; + } + + // Regular clarification flow (not planning questions) logger.info({ issueId }, 'Checkbox debounce timer fired - triggering re-evaluation'); // Clear awaiting response state @@ -390,10 +530,10 @@ async function handleCommentUpdate(data: WebhookCommentData): Promise { }); logger.info({ issueId, ticketIdentifier }, 'Enqueued refine from checkbox update (after debounce)'); - }, CHECKBOX_DEBOUNCE_MS); + }, debounceMs); checkboxDebounceTimers.set(issueId, timer); - logger.debug({ issueId, debounceMs: CHECKBOX_DEBOUNCE_MS }, 'Set checkbox debounce timer'); + logger.debug({ issueId, debounceMs }, 'Set checkbox debounce timer'); return; }