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
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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: {
Expand Down
49 changes: 43 additions & 6 deletions src/queue/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand Down
178 changes: 159 additions & 19 deletions src/webhook/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, NodeJS.Timeout>();
const lastCheckboxChange = new Map<string, Date>();

// Track question count per ticket for dynamic debounce calculation
const questionCountCache = new Map<string, number>();

// Track completion signals per ticket
const completionSignalReceived = new Map<string, boolean>();

/**
* 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
Expand Down Expand Up @@ -215,35 +270,61 @@ async function handleCommentCreate(data: WebhookCommentData): Promise<void> {
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;
Expand Down Expand Up @@ -345,8 +426,12 @@ async function handleCommentUpdate(data: WebhookCommentData): Promise<void> {
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'
);

Expand All @@ -360,11 +445,66 @@ async function handleCommentUpdate(data: WebhookCommentData): Promise<void> {
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
Expand All @@ -390,10 +530,10 @@ async function handleCommentUpdate(data: WebhookCommentData): Promise<void> {
});

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;
}
Expand Down