/ src / agents / triage.js
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  }