triage.js
1 /** 2 * Triage Agent 3 * 4 * Entry point for error handling and task distribution. 5 * Classifies errors, determines severity, routes to appropriate agents. 6 */ 7 8 import { BaseAgent } from './base-agent.js'; 9 import { distance as levenshtein } from 'fastest-levenshtein'; 10 import { getOne, getAll, run } from '../utils/db.js'; 11 12 export class TriageAgent extends BaseAgent { 13 constructor() { 14 super('triage', ['base.md', 'triage.md']); 15 } 16 17 /** 18 * Process a triage task 19 * 20 * @param {Object} task - Task object 21 * @returns {Promise<void>} 22 */ 23 async processTask(task) { 24 try { 25 // Parse context_json if needed 26 const context = 27 task.context_json && typeof task.context_json === 'string' 28 ? JSON.parse(task.context_json) 29 : task.context_json || {}; 30 31 // Ensure context is attached to task for handlers 32 task.context_json = context; 33 34 switch (task.task_type) { 35 case 'classify_error': 36 await this.classifyErrorTask(task); 37 break; 38 39 case 'route_task': 40 await this.routeTask(task); 41 break; 42 43 case 'prioritize_tasks': 44 await this.prioritizeTasks(task); 45 break; 46 47 // Task types assigned to wrong agent - delegate 48 case 'implement_feature': 49 case 'fix_bug': 50 await this.delegateToCorrectAgent(task); 51 break; 52 53 default: 54 // Unknown task types - delegate to correct agent via task routing 55 await this.log('warn', 'Unknown task type received, delegating', { 56 task_id: task.id, 57 task_type: task.task_type, 58 }); 59 await this.delegateToCorrectAgent(task); 60 } 61 } catch (error) { 62 await this.log( 63 'error', 64 `${this.agentName.charAt(0).toUpperCase() + this.agentName.slice(1)} task ${task.id} failed: ${error.message}`, 65 { 66 task_id: task.id, 67 task_type: task.task_type, 68 error: error.message, 69 stack: error.stack, 70 } 71 ); 72 throw error; // Re-throw so task manager can handle 73 } 74 } 75 /** 76 * Classify an error and route to appropriate agent 77 * 78 * @param {Object} task - Task with error details in context_json 79 * @returns {Promise<void>} 80 */ 81 async classifyErrorTask(task) { 82 const context = task.context_json || {}; 83 const { 84 error_type: contextErrorType, 85 error_message, 86 stack_trace, 87 stage, 88 frequency = 1, 89 } = context; 90 91 if (!error_message) { 92 await this.completeTask(task.id, { 93 classification: 'unknown', 94 routed_to: null, 95 skipped: true, 96 reason: 'Missing required field: error_message', 97 }); 98 return; 99 } 100 101 // Suppress test/fixture errors from reaching the developer queue 102 const testDataPattern = 103 /\.test\.js|tests\/|test-fixtures|__mocks__|example\.com|failing\.com|test\.com|site 99999|outreach #\d+|\+1234567890|\+15005550/i; 104 if (testDataPattern.test(error_message) || testDataPattern.test(stack_trace || '')) { 105 await this.log('info', 'Skipping test-data error (not a production issue)', { 106 task_id: task.id, 107 error_message: (error_message || '').substring(0, 200), 108 }); 109 await this.completeTask(task.id, { classification: 'test_data', skipped: true }); 110 return; 111 } 112 113 // Stage stalls: operational (rate-limit/backlog) → dismiss. Crash loops → escalate to developer. 114 if (contextErrorType === 'stage_stalled') { 115 await this.completeTask(task.id, { 116 classification: 'stage_stalled', 117 severity: 'low', 118 routed_to: null, 119 skipped: true, 120 reason: 121 'Stage stall is an expected operational state (rate-limiting or backlog). No code fix needed.', 122 }); 123 return; 124 } 125 126 if (contextErrorType === 'stage_crash_loop') { 127 // Stage is crashing on every run — treat like a code bug and route to developer 128 const { stage } = task.context_json || {}; 129 const stageFileMap = { 130 serps: 'src/stages/serps.js', 131 assets: 'src/stages/assets.js', 132 scoring: 'src/stages/scoring.js', 133 rescoring: 'src/stages/rescoring.js', 134 enrich: 'src/stages/enrich.js', 135 proposals: 'src/stages/proposals.js', 136 outreach: 'src/stages/outreach.js', 137 replies: 'src/stages/replies.js', 138 }; 139 const affectedFile = stageFileMap[stage?.toLowerCase()] || `src/stages/${stage}.js`; 140 const newTaskId = await this.createTask({ 141 task_type: 'fix_bug', 142 assigned_to: 'developer', 143 priority: 9, 144 parent_task_id: task.id, 145 context: { 146 error_type: 'stage_crash_loop', 147 error_message, 148 stack_trace, 149 stage, 150 severity: 'critical', 151 file_path: affectedFile, 152 reason: `${stage} stage is crashing on every pipeline run — all outbound messages blocked until fixed.`, 153 }, 154 }); 155 await this.completeTask(task.id, { 156 classification: 'stage_crash_loop', 157 severity: 'critical', 158 routed_to: 'developer', 159 fix_bug_task_id: newTaskId, 160 reason: `Routed to developer: ${stage} stage crash loop detected.`, 161 }); 162 return; 163 } 164 165 // Step 0: Check known error database first 166 const knownFix = await this.checkKnownErrorDatabase(error_message, stack_trace); 167 168 if (knownFix) { 169 await this.log('info', 'Known error detected', { 170 task_id: task.id, 171 error_message: error_message.substring(0, 200), 172 known_fix_task_id: knownFix.task_id, 173 similarity: knownFix.similarity, 174 }); 175 176 // If the known fix involved no code changes, this error resolves itself — dismiss 177 if (!knownFix.files_changed || knownFix.files_changed.length === 0) { 178 await this.completeTask(task.id, { 179 classification: 'known_operational', 180 severity: 'low', 181 routed_to: null, 182 skipped: true, 183 reason: `Previously resolved without code changes (task #${knownFix.task_id}, similarity ${(knownFix.similarity * 100).toFixed(0)}%). Self-resolving operational state.`, 184 known_fix: knownFix, 185 }); 186 return; 187 } 188 189 // Route with lower priority since we have a known fix 190 const newTaskId = await this.createTask({ 191 task_type: 'fix_bug', 192 assigned_to: 'developer', 193 priority: 5, // Lower priority for known fixes 194 parent_task_id: task.id, 195 context: { 196 error_type: knownFix.error_type || 'known_error', 197 error_message, 198 stack_trace, 199 stage, 200 severity: 'low', // Known fixes are low severity 201 frequency, 202 suggested_fix: knownFix.fix_description, 203 known_fix: knownFix, // Include full known fix data 204 file_path: this.resolveFilePath(error_message, stack_trace, stage), 205 }, 206 }); 207 208 await this.log('info', 'Known error routed', { 209 task_id: task.id, 210 new_task_id: newTaskId, 211 routed_to: 'developer', 212 priority: 5, 213 }); 214 215 // Complete triage task 216 await this.completeTask(task.id, { 217 classification: 'known_error', 218 severity: 'low', 219 routed_to: 'developer', 220 priority: 5, 221 new_task_id: newTaskId, 222 known_fix: knownFix, 223 }); 224 225 return; 226 } 227 228 // Step 1: Classify error type 229 const classification = this.classifyError(error_message, stack_trace); 230 231 await this.log('info', 'Error classified', { 232 task_id: task.id, 233 error_type: classification.type, 234 error_message: error_message.substring(0, 200), 235 }); 236 237 // Step 2: Determine severity 238 const { severity, basePriority } = this.determineSeverity(error_message, stage, frequency); 239 240 // Step 3: Route to agent 241 const assignee = this.routeToAgent(classification.type, severity, { 242 stage, 243 involves_security: /security|auth|unauthorized|forbidden/i.test(error_message), 244 schema_change_needed: /ALTER TABLE|DROP TABLE|ADD COLUMN/i.test(error_message), 245 test_failure: /test.*fail|assertion.*fail/i.test(error_message), 246 }); 247 248 // Step 4: Calculate final priority 249 const priority = this.calculatePriorityFromClassification({ 250 ...classification, 251 severity, 252 stage, 253 frequency, 254 }); 255 256 // Step 5: Get suggested fix 257 const suggestedFix = this.suggestFix(classification.type, error_message); 258 259 // Step 6: Create task for assigned agent 260 const newTaskId = await this.createTask({ 261 task_type: 'fix_bug', 262 assigned_to: assignee, 263 priority, 264 parent_task_id: task.id, 265 context: { 266 error_type: classification.type, 267 error_message, 268 stack_trace, 269 stage, 270 severity, 271 frequency, 272 suggested_fix: suggestedFix, 273 file_path: this.resolveFilePath(error_message, stack_trace, stage), 274 }, 275 }); 276 277 await this.log('info', 'Error routed', { 278 task_id: task.id, 279 new_task_id: newTaskId, 280 routed_to: assignee, 281 severity, 282 priority, 283 }); 284 285 // Step 7: Complete triage task 286 await this.completeTask(task.id, { 287 classification: classification.type, 288 severity, 289 routed_to: assignee, 290 priority, 291 new_task_id: newTaskId, 292 }); 293 } 294 295 /** 296 * Classify error by type 297 * 298 * @param {string} errorMessage - Error message 299 * @param {string} [stackTrace] - Stack trace 300 * @returns {Object} - Classification result 301 */ 302 classifyError(errorMessage, stackTrace = '') { 303 const message = `${errorMessage} ${stackTrace}`.toLowerCase(); 304 305 // Agent system errors (don't trigger circuit breaker) 306 if ( 307 /unknown task type|not implemented|task type.*required|validation.*failed|invalid.*task/i.test( 308 message 309 ) 310 ) { 311 return { 312 type: 'agent_system_error', 313 severity: 'low', // Don't trigger circuit breaker 314 assignee: 'architect', // Route to architect instead of developer 315 basePriority: 4, 316 is_agent_error: true, // Flag to exclude from circuit breaker 317 }; 318 } 319 320 // Null pointer / undefined 321 if (/cannot read property|undefined|typeerror.*null/i.test(message)) { 322 return { 323 type: 'null_pointer', 324 severity: 'medium', 325 assignee: 'developer', 326 basePriority: 6, 327 }; 328 } 329 330 // Network errors 331 if (/enotfound|etimedout|econnrefused|ehostunreach|network/i.test(message)) { 332 return { 333 type: 'network', 334 severity: 'medium', 335 assignee: 'architect', // Infrastructure issue 336 basePriority: 6, 337 }; 338 } 339 340 // Database constraints 341 if (/unique constraint|foreign key|sql syntax/i.test(message)) { 342 return { 343 type: 'database', 344 severity: 'high', 345 assignee: 'developer', 346 basePriority: 7, 347 }; 348 } 349 350 // API errors 351 if (/status (4|5)\d\d|rate limit|quota exceeded/i.test(message)) { 352 return { 353 type: 'api_error', 354 severity: 'medium', 355 assignee: 'developer', 356 basePriority: 5, 357 }; 358 } 359 360 // Security issues 361 if (/unauthorized|forbidden|invalid signature|security/i.test(message)) { 362 return { 363 type: 'security', 364 severity: 'critical', 365 assignee: 'security', 366 basePriority: 10, 367 }; 368 } 369 370 // Configuration errors 371 if ( 372 /environment variable|config|missing.*api[_-]?key|missing.*secret|missing.*token/i.test( 373 message 374 ) 375 ) { 376 return { 377 type: 'configuration', 378 severity: 'high', 379 assignee: 'developer', 380 basePriority: 8, 381 }; 382 } 383 384 // Performance issues 385 if (/timeout|slow.*query|memory.*leak|heap.*out.*of.*memory/i.test(message)) { 386 return { 387 type: 'performance', 388 severity: 'medium', 389 assignee: 'architect', 390 basePriority: 6, 391 }; 392 } 393 394 // Integration errors (external services) 395 if (/resend|twilio|zenrows|openrouter|cloudflare/i.test(message)) { 396 return { 397 type: 'integration', 398 severity: 'medium', 399 assignee: 'developer', 400 basePriority: 6, 401 }; 402 } 403 404 // Circuit breaker 405 if (/breaker.*open|circuit.*open/i.test(message)) { 406 return { 407 type: 'circuit_breaker', 408 severity: 'high', 409 assignee: 'architect', 410 basePriority: 7, 411 }; 412 } 413 414 // Validation errors 415 if (/invalid.*input|schema.*mismatch|validation.*failed/i.test(message)) { 416 return { 417 type: 'validation', 418 severity: 'low', 419 assignee: 'developer', 420 basePriority: 5, 421 }; 422 } 423 424 // Default: unknown 425 return { 426 type: 'unknown', 427 severity: 'medium', 428 assignee: 'developer', 429 basePriority: 5, 430 }; 431 } 432 433 /** 434 * Determine severity based on error message, stage, and frequency 435 * 436 * @param {string} errorMessage - Error message 437 * @param {string} stage - Pipeline stage where error occurred 438 * @param {number} frequency - Number of occurrences 439 * @returns {Object} - { severity, basePriority } 440 */ 441 determineSeverity(errorMessage, stage, frequency) { 442 let severity = 'medium'; 443 let basePriority = 5; 444 445 // Critical if security-related 446 if (/security|breach|unauthorized|forbidden/i.test(errorMessage)) { 447 severity = 'critical'; 448 basePriority = 10; 449 } 450 // High if in early pipeline stages (affects more downstream) 451 else if (['keywords', 'serps', 'assets'].includes(stage)) { 452 severity = 'high'; 453 basePriority = 7; 454 } 455 // High if occurs frequently (>10 times) 456 else if (frequency > 10) { 457 severity = 'high'; 458 basePriority = 7; 459 } 460 // High if data loss risk 461 else if (/database|transaction|rollback|corrupt/i.test(errorMessage)) { 462 severity = 'high'; 463 basePriority = 8; 464 } 465 // Low if validation or cosmetic 466 else if (/validation|invalid input|cosmetic/i.test(errorMessage)) { 467 severity = 'low'; 468 basePriority = 4; 469 } 470 471 return { severity, basePriority }; 472 } 473 474 /** 475 * Route task to appropriate agent 476 * 477 * @param {string} errorType - Error type 478 * @param {string} severity - Severity level 479 * @param {Object} context - Additional context 480 * @returns {string} - Agent name 481 */ 482 routeToAgent(errorType, severity, context = {}) { 483 // Security always goes to Security Agent 484 if (errorType === 'security' || (severity === 'critical' && context.involves_security)) { 485 return 'security'; 486 } 487 488 // Infrastructure/network issues go to Architect 489 if (errorType === 'network' || errorType === 'performance' || errorType === 'circuit_breaker') { 490 return 'architect'; 491 } 492 493 // Database schema issues go to Architect 494 if (errorType === 'database' && context.schema_change_needed) { 495 return 'architect'; 496 } 497 498 // Test failures go to QA 499 if (context.test_failure) { 500 return 'qa'; 501 } 502 503 // Everything else defaults to Developer 504 return 'developer'; 505 } 506 507 /** 508 * Calculate final priority from classification 509 * 510 * @param {Object} classification - Classification result 511 * @returns {number} - Priority (1-10) 512 */ 513 calculatePriorityFromClassification(classification) { 514 let priority = classification.basePriority || 5; 515 516 // Severity boost 517 if (classification.severity === 'critical') priority += 5; 518 else if (classification.severity === 'high') priority += 2; 519 else if (classification.severity === 'low') priority -= 2; 520 521 // Stage boost (earlier stages = higher priority) 522 const earlyStageBoost = { 523 keywords: 2, 524 serps: 2, 525 assets: 1, 526 scoring: 0, 527 rescoring: 0, 528 enrich: -1, 529 proposals: -1, 530 outreach: -1, 531 replies: -1, 532 }; 533 priority += earlyStageBoost[classification.stage] || 0; 534 535 // Frequency boost 536 if (classification.frequency > 10) priority += 2; 537 else if (classification.frequency > 5) priority += 1; 538 539 // Cap at 1-10 540 return Math.max(1, Math.min(10, priority)); 541 } 542 543 /** 544 * Suggest fix for common error types 545 * 546 * @param {string} errorType - Error type 547 * @param {string} errorMessage - Error message 548 * @returns {string} - Suggested fix 549 */ 550 suggestFix(errorType, errorMessage) { 551 switch (errorType) { 552 case 'null_pointer': 553 return 'Add null check with optional chaining: obj?.property || defaultValue'; 554 555 case 'database': 556 if (/unique constraint/i.test(errorMessage)) { 557 return 'Check for existing record before INSERT, or use INSERT OR IGNORE'; 558 } 559 return 'Review database schema and query syntax'; 560 561 case 'network': 562 return 'Add retry logic with retryWithBackoff() for transient failures'; 563 564 case 'api_error': 565 if (/429|rate limit/i.test(errorMessage)) { 566 return 'Implement rate limiting and exponential backoff'; 567 } 568 if (/401|unauthorized/i.test(errorMessage)) { 569 return 'Check API key is set in environment variables'; 570 } 571 return 'Add error handling for API failures'; 572 573 case 'configuration': 574 return 'Check .env file has required environment variables set'; 575 576 case 'performance': 577 if (/timeout/i.test(errorMessage)) { 578 return 'Increase timeout or optimize slow operation'; 579 } 580 return 'Profile and optimize performance bottleneck'; 581 582 case 'circuit_breaker': 583 return 'Investigate upstream service failures, circuit breaker will auto-reset'; 584 585 case 'security': 586 return 'Review security implications, add to human_review_queue if critical'; 587 588 default: 589 return 'Investigate root cause and implement appropriate fix'; 590 } 591 } 592 593 /** 594 * Route a generic task to appropriate agent 595 * 596 * @param {Object} task - Task object 597 * @returns {Promise<void>} 598 */ 599 async routeTask(task) { 600 const contextData = task.context_json || {}; 601 const { task_description, task_type, context } = contextData; 602 603 // Simple routing logic based on keywords 604 let assignee = 'developer'; // Default 605 606 if (/test|coverage|verify/i.test(task_description)) { 607 assignee = 'qa'; 608 } else if (/security|audit|compliance/i.test(task_description)) { 609 assignee = 'security'; 610 } else if (/design|architecture|refactor/i.test(task_description)) { 611 assignee = 'architect'; 612 } 613 614 const newTaskId = await this.createTask({ 615 task_type: task_type || 'general_task', 616 assigned_to: assignee, 617 priority: 5, 618 parent_task_id: task.id, 619 context: context || { description: task_description }, 620 }); 621 622 await this.completeTask(task.id, { 623 routed_to: assignee, 624 new_task_id: newTaskId, 625 }); 626 } 627 628 /** 629 * Prioritize pending tasks 630 * 631 * @param {Object} task - Task object 632 * @returns {Promise<void>} 633 */ 634 async prioritizeTasks(task) { 635 await this.log('info', 'Prioritizing pending tasks', { 636 task_id: task.id, 637 }); 638 639 try { 640 // Get all pending tasks that need prioritization 641 const pendingTasks = await getAll( 642 `SELECT id, task_type, created_at, context_json 643 FROM tel.agent_tasks 644 WHERE status = 'pending' 645 AND priority IS NULL 646 ORDER BY created_at DESC` 647 ); 648 649 if (pendingTasks.length === 0) { 650 await this.completeTask(task.id, { 651 message: 'No pending tasks requiring prioritization', 652 tasks_prioritized: 0, 653 }); 654 return; 655 } 656 657 let prioritized = 0; 658 659 for (const pendingTask of pendingTasks) { 660 let context; 661 try { 662 context = pendingTask.context_json ? JSON.parse(pendingTask.context_json) : {}; 663 } catch (e) { 664 context = {}; 665 } 666 667 const priority = this.calculatePriority(pendingTask, context); 668 669 // Update task priority 670 await run( 671 'UPDATE tel.agent_tasks SET priority = $1 WHERE id = $2', 672 [priority, pendingTask.id] 673 ); 674 675 prioritized++; 676 } 677 678 await this.log('info', 'Task prioritization completed', { 679 task_id: task.id, 680 tasks_prioritized: prioritized, 681 }); 682 683 await this.completeTask(task.id, { 684 message: `Prioritized ${prioritized} pending tasks`, 685 tasks_prioritized: prioritized, 686 }); 687 } catch (error) { 688 await this.handleError(error, task, 'Failed to prioritize tasks'); 689 } 690 } 691 692 /** 693 * Calculate priority score for a task 694 * 695 * Lower score = higher priority (1 is highest, 10 is lowest) 696 * 697 * Scoring factors: 698 * - Error frequency (0-3 points): More frequent errors get higher priority 699 * - Severity (0-2 points): High severity gets higher priority 700 * - Age (0-1 point): Older errors get higher priority 701 * - Pipeline impact (0-2 points): Errors blocking critical stages get higher priority 702 * 703 * @param {Object} task - Task object 704 * @param {Object} context - Task context 705 * @returns {number} - Priority score (1-10, lower is higher priority) 706 */ 707 calculatePriority(task, context) { 708 let score = 5; // Base priority (medium) 709 710 // Frequency scoring (0-3 points reduction) 711 const frequency = context.frequency || 1; 712 if (frequency > 100) { 713 score -= 3; // Very frequent error 714 } else if (frequency > 10) { 715 score -= 2; // Moderately frequent 716 } else if (frequency > 1) { 717 score -= 1; // Occasional 718 } 719 720 // Severity scoring (0-2 points reduction) 721 const severity = context.severity || 'medium'; 722 if (severity === 'high' || severity === 'critical') { 723 score -= 2; 724 } else if (severity === 'medium') { 725 score -= 1; 726 } 727 728 // Age scoring (0-1 point reduction) 729 const createdAt = new Date(task.created_at); 730 const ageHours = (Date.now() - createdAt.getTime()) / (1000 * 60 * 60); 731 if (ageHours > 24) { 732 score -= 1; // Old task, needs attention 733 } 734 735 // Pipeline impact scoring (0-2 points reduction) 736 // Critical stages: scoring (blocks grading), assets (blocks scoring), proposals (blocks revenue) 737 const stage = context.stage || ''; 738 const criticalStages = ['scoring', 'rescoring', 'assets', 'proposals']; 739 if (criticalStages.includes(stage.toLowerCase())) { 740 score -= 2; 741 } 742 743 // Error type scoring (0-1 point reduction) 744 // Some error types are more critical than others 745 const errorType = context.error_type || ''; 746 const criticalErrors = ['database', 'auth', 'security', 'data_loss']; 747 if (criticalErrors.some(ce => errorType.toLowerCase().includes(ce))) { 748 score -= 1; 749 } 750 751 // Ensure priority is in valid range (1-10) 752 return Math.max(1, Math.min(10, score)); 753 } 754 755 /** 756 * Resolve a file path from error message + stage context. 757 * Lets triage provide file_path so the developer agent never blocks on clarification. 758 * 759 * @param {string} errorMessage - Error message text 760 * @param {string} stackTrace - Stack trace (may be empty) 761 * @param {string} stage - Pipeline stage name (e.g. 'scoring', 'enrich') 762 * @returns {string|null} - Relative file path or null 763 */ 764 resolveFilePath(errorMessage, stackTrace = '', stage = '') { 765 const combined = `${errorMessage}\n${stackTrace}`; 766 767 // Priority 1: explicit src/ path in stack trace 768 const stackSrc = combined.match(/(src\/[a-z0-9/_-]+\.js)/i); 769 if (stackSrc) return stackSrc[1]; 770 771 // Priority 2: known error message patterns → file 772 const errorPatterns = [ 773 { pattern: /Country code is required/i, file: 'src/config/countries.js' }, 774 { pattern: /browserType\.launch/i, file: 'src/utils/stealth-browser.js' }, 775 { pattern: /html_dom.*NULL|html_dom is null/i, file: 'src/stages/assets.js' }, 776 { pattern: /incomplete LLM response/i, file: 'src/score.js' }, 777 { pattern: /Failed to parse JSON response/i, file: 'src/score.js' }, 778 { pattern: /database is locked/i, file: 'src/pipeline-service.js' }, 779 { pattern: /GDPR/i, file: 'src/stages/enrich.js' }, 780 { pattern: /recapture_at/i, file: 'src/stages/assets.js' }, 781 { pattern: /ZenRows/i, file: 'src/scrape.js' }, 782 { pattern: /Twilio/i, file: 'src/outreach/sms.js' }, 783 { pattern: /Resend/i, file: 'src/outreach/email.js' }, 784 { 785 pattern: /Failed to send reply|send.*reply.*conversation/i, 786 file: 'src/inbound/processor.js', 787 }, 788 { pattern: /Failed to process email event|email event/i, file: 'src/inbound/email.js' }, 789 { pattern: /no such column.*raw_payload/i, file: 'src/inbound/email.js' }, 790 { pattern: /no such column/i, file: null }, // resolved via stage name (see stageFileMap below) 791 { pattern: /no such table.*messages/i, file: 'src/stages/outreach.js' }, 792 { 793 pattern: /Circuit breaker.*OpenRouter|OpenRouter.*circuit/i, 794 file: 'src/utils/circuit-breaker.js', 795 }, 796 { pattern: /rate.?limit|429 Too Many/i, file: 'src/utils/rate-limiter.js' }, 797 ]; 798 for (const { pattern, file } of errorPatterns) { 799 if (pattern.test(errorMessage)) return file; 800 } 801 802 // Priority 3: stage name → canonical stage file 803 const stageFileMap = { 804 serps: 'src/stages/serps.js', 805 assets: 'src/stages/assets.js', 806 scoring: 'src/stages/scoring.js', 807 rescoring: 'src/stages/rescoring.js', 808 enrich: 'src/stages/enrich.js', 809 enrichment: 'src/stages/enrich.js', 810 proposals: 'src/stages/proposals.js', 811 outreach: 'src/stages/outreach.js', 812 replies: 'src/stages/replies.js', 813 }; 814 const stageLower = (stage || '').toLowerCase(); 815 if (stageFileMap[stageLower]) return stageFileMap[stageLower]; 816 817 return null; 818 } 819 820 /** 821 * Check known error database for similar past errors 822 * 823 * Searches completed fix_bug tasks for similar errors and returns known fixes. 824 * Normalizes error messages to remove specifics (line numbers, IDs, etc.) 825 * 826 * @param {string} errorMessage - Current error message 827 * @param {string} [stackTrace] - Stack trace 828 * @returns {Object|null} - Known fix data or null if not found 829 */ 830 async checkKnownErrorDatabase(errorMessage, stackTrace = '') { 831 // Normalize current error 832 const normalizedError = this.normalizeErrorMessage(errorMessage, stackTrace); 833 834 // Query completed fix_bug tasks 835 const completedFixes = await getAll( 836 `SELECT id, context_json, result_json, completed_at 837 FROM tel.agent_tasks 838 WHERE task_type = 'fix_bug' 839 AND status = 'completed' 840 AND result_json IS NOT NULL 841 ORDER BY completed_at DESC 842 LIMIT 100` 843 ); 844 845 // Search for similar errors 846 let bestMatch = null; 847 let bestSimilarity = 0; 848 849 for (const fix of completedFixes) { 850 try { 851 const context = JSON.parse(fix.context_json); 852 const result = JSON.parse(fix.result_json); 853 854 if (!context.error_message) continue; 855 856 // Normalize past error 857 const normalizedPastError = this.normalizeErrorMessage( 858 context.error_message, 859 context.stack_trace || '' 860 ); 861 862 // Calculate similarity (simple string matching for now) 863 const similarity = this.calculateSimilarity(normalizedError, normalizedPastError); 864 865 // Require 70% similarity to consider it a match 866 if (similarity >= 0.7 && similarity > bestSimilarity) { 867 bestSimilarity = similarity; 868 bestMatch = { 869 task_id: fix.id, 870 error_type: context.error_type, 871 error_message: context.error_message, 872 fix_description: result.fix_description || result.summary || 'See task details', 873 files_changed: result.files_changed || [], 874 completed_at: fix.completed_at, 875 similarity, 876 }; 877 } 878 } catch (_e) { 879 // Skip malformed JSON 880 continue; 881 } 882 } 883 884 return bestMatch; 885 } 886 887 /** 888 * Normalize error message by removing specifics 889 * 890 * Removes: line numbers, column numbers, file paths, IDs, timestamps, specific values 891 * 892 * @param {string} errorMessage - Error message 893 * @param {string} [stackTrace] - Stack trace 894 * @returns {string} - Normalized error message 895 */ 896 normalizeErrorMessage(errorMessage, stackTrace = '') { 897 let normalized = `${errorMessage} ${stackTrace}`; 898 899 // Remove line:column numbers (e.g., "file.js:179:45" -> "file.js") 900 normalized = normalized.replace(/:\d+:\d+/g, ''); 901 normalized = normalized.replace(/:\d+/g, ''); 902 903 // Remove file paths (keep just filename) 904 normalized = normalized.replace(/[/\\][\w/\\-]+[/\\]/g, ''); 905 906 // Normalize quotes (convert all quotes to single style) 907 normalized = normalized.replace(/[""]/g, '"'); 908 normalized = normalized.replace(/['']/g, "'"); 909 910 // Remove specific IDs (numbers in quotes, site_id=123, id: 123) 911 normalized = normalized.replace(/\b(id|site_id|task_id|user_id)[=:\s]+\d+/gi, '$1=ID'); 912 normalized = normalized.replace(/["']\d+["']/g, '"ID"'); 913 914 // Remove timestamps (various formats) 915 normalized = normalized.replace(/\d{4}-\d{2}-\d{2}[\sT]\d{2}:\d{2}:\d{2}/g, 'timestamp'); 916 normalized = normalized.replace(/\d{4}-\d{2}-\d{2}/g, 'timestamp'); 917 918 // Remove specific URLs/domains 919 normalized = normalized.replace(/https?:\/\/[^\s]+/g, 'URL'); 920 921 // Remove specific numeric values that might vary (but keep small numbers for error codes) 922 normalized = normalized.replace(/\b\d{3,}\b/g, 'NUM'); 923 924 // Normalize whitespace 925 normalized = normalized.replace(/\s+/g, ' ').trim(); 926 927 // Convert to lowercase for comparison 928 normalized = normalized.toLowerCase(); 929 930 return normalized; 931 } 932 933 /** 934 * Calculate similarity between two normalized error messages 935 * 936 * Uses a hybrid approach: 937 * 1. Jaccard similarity (word overlap) - 50% weight 938 * 2. Levenshtein distance (character-level) - 50% weight 939 * 940 * This catches both semantic similarity (same words) and syntactic similarity (similar phrasing) 941 * 942 * @param {string} error1 - First normalized error 943 * @param {string} error2 - Second normalized error 944 * @returns {number} - Similarity score (0-1) 945 */ 946 calculateSimilarity(error1, error2) { 947 // Handle empty strings 948 if (!error1 && !error2) return 1; // Both empty = identical 949 if (!error1 || !error2) return 0; // One empty = different 950 if (error1 === error2) return 1; 951 952 // 1. Jaccard similarity (word-level) 953 const words1 = new Set(error1.split(/\s+/).filter(w => w.length > 0)); 954 const words2 = new Set(error2.split(/\s+/).filter(w => w.length > 0)); 955 956 const intersection = new Set([...words1].filter(word => words2.has(word))); 957 const union = new Set([...words1, ...words2]); 958 959 const jaccardSimilarity = union.size === 0 ? 0 : intersection.size / union.size; 960 961 // 2. Levenshtein distance (character-level) 962 const distance = levenshtein(error1, error2); 963 const maxLen = Math.max(error1.length, error2.length); 964 const levenshteinSimilarity = maxLen === 0 ? 1 : 1 - distance / maxLen; 965 966 // 3. Combine both scores (50/50 weight) 967 // Jaccard is better for structural similarity (same error type) 968 // Levenshtein is better for catching typos and small variations 969 const combinedSimilarity = (jaccardSimilarity + levenshteinSimilarity) / 2; 970 971 return combinedSimilarity; 972 } 973 974 }