From e5391a05a5c55e181452e48512f30276b3826e8b Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 4 Apr 2026 22:03:42 -0700 Subject: [PATCH] feat(FN-957): filter silent transient errors from executor and triage logs - Add isSilentTransientError() function to detect noisy transient errors (ETIMEDOUT, ENOTFOUND, ECONNRESET, etc.) - Filter silent transient errors from executor error logging to reduce noise - Filter silent transient errors from triage error logging to reduce noise - Add unit tests for isSilentTransientError covering all error patterns --- packages/engine/src/executor.ts | 9 +++-- .../src/transient-error-detector.test.ts | 34 ++++++++++++++++++ .../engine/src/transient-error-detector.ts | 36 +++++++++++++++++++ packages/engine/src/triage.ts | 9 +++-- 4 files changed, 82 insertions(+), 6 deletions(-) diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 60378ce0e..524f45d41 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -13,7 +13,7 @@ import type { WorktreePool } from "./worktree-pool.js"; import { AgentLogger } from "./agent-logger.js"; import { executorLog, reviewerLog } from "./logger.js"; import { isUsageLimitError, checkSessionError, type UsageLimitPauser } from "./usage-limit-detector.js"; -import { isTransientError } from "./transient-error-detector.js"; +import { isTransientError, isSilentTransientError } from "./transient-error-detector.js"; import { withRateLimitRetry } from "./rate-limit-retry.js"; import { computeRecoveryDecision, formatDelay, MAX_RECOVERY_RETRIES } from "./recovery-policy.js"; import type { StuckTaskDetector, StuckTaskEvent } from "./stuck-task-detector.js"; @@ -967,8 +967,11 @@ export class TaskExecutor { if (decision.shouldRetry) { const attempt = decision.nextState.recoveryRetryCount; const delay = formatDelay(decision.delayMs); - executorLog.warn(`⚡ ${task.id} transient error — retry ${attempt}/${MAX_RECOVERY_RETRIES} in ${delay}: ${err.message}`); - await this.store.logEntry(task.id, `Transient error (retry ${attempt}/${MAX_RECOVERY_RETRIES} in ${delay}): ${err.message}`); + // Silent transient errors (e.g., "request was aborted") are noisy — skip logging + if (!isSilentTransientError(err.message)) { + executorLog.warn(`⚡ ${task.id} transient error — retry ${attempt}/${MAX_RECOVERY_RETRIES} in ${delay}: ${err.message}`); + await this.store.logEntry(task.id, `Transient error (retry ${attempt}/${MAX_RECOVERY_RETRIES} in ${delay}): ${err.message}`); + } await this.store.updateTask(task.id, { recoveryRetryCount: decision.nextState.recoveryRetryCount, nextRecoveryAt: decision.nextState.nextRecoveryAt, diff --git a/packages/engine/src/transient-error-detector.test.ts b/packages/engine/src/transient-error-detector.test.ts index 5d8276b1e..a0d6b6e6a 100644 --- a/packages/engine/src/transient-error-detector.test.ts +++ b/packages/engine/src/transient-error-detector.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest"; import { isTransientError, classifyError, + isSilentTransientError, TRANSIENT_ERROR_PATTERNS, } from "./transient-error-detector.js"; import { isUsageLimitError } from "./usage-limit-detector.js"; @@ -202,4 +203,37 @@ describe("Transient Error Detector", () => { }); }); }); + + describe("isSilentTransientError", () => { + it("returns true for 'request was aborted'", () => { + expect(isSilentTransientError("request was aborted")).toBe(true); + expect(isSilentTransientError("Request was aborted")).toBe(true); + expect(isSilentTransientError("REQUEST WAS ABORTED")).toBe(true); + expect(isSilentTransientError("Error: request was aborted")).toBe(true); + }); + + it("returns false for other transient errors", () => { + expect(isSilentTransientError("ECONNREFUSED")).toBe(false); + expect(isSilentTransientError("socket hang up")).toBe(false); + expect(isSilentTransientError("upstream connect error")).toBe(false); + expect(isSilentTransientError("connection reset")).toBe(false); + }); + + it("returns false for non-transient errors", () => { + expect(isSilentTransientError("SyntaxError: Unexpected token")).toBe(false); + expect(isSilentTransientError("Test failed")).toBe(false); + }); + + it("returns false for empty/invalid input", () => { + expect(isSilentTransientError("")).toBe(false); + expect(isSilentTransientError(null as unknown as string)).toBe(false); + expect(isSilentTransientError(undefined as unknown as string)).toBe(false); + }); + + it("returns false for partial matches like 'abort' alone", () => { + expect(isSilentTransientError("abort")).toBe(false); + expect(isSilentTransientError("Aborted")).toBe(false); + expect(isSilentTransientError("The operation was aborted by user")).toBe(false); + }); + }); }); diff --git a/packages/engine/src/transient-error-detector.ts b/packages/engine/src/transient-error-detector.ts index 19faecf5f..de8501e19 100644 --- a/packages/engine/src/transient-error-detector.ts +++ b/packages/engine/src/transient-error-detector.ts @@ -80,6 +80,42 @@ export function isTransientError(errorMessage: string): boolean { return TRANSIENT_ERROR_PATTERNS.some((pattern) => pattern.test(errorMessage)); } +/** + * Patterns for transient errors that should be silently retried without + * logging to task log entries. These errors are extremely noisy (high frequency) + * but harmless — the retry succeeds on the next attempt. + * + * Silent transient errors: + * - "request was aborted" — AI provider streaming cancellations (very noisy, + * occurs frequently when providers drop in-flight requests) + */ +const SILENT_TRANSIENT_PATTERNS: RegExp[] = [ + /request was aborted/i, +]; + +/** + * Check if an error message indicates a "silent" transient error that should + * NOT be logged to task log entries. + * + * Silent transient errors are a subset of transient errors (identified by + * {@link isTransientError}) that are extremely noisy in practice. While they + * still trigger the normal retry mechanism (task moves back to "todo"), they + * are suppressed from the task log to reduce noise in dashboard views. + * + * All silent transient errors are also transient errors — this function + * returns `true` only for errors that {@link isTransientError} would also + * match. The distinction is purely about logging behavior, not retry behavior. + * + * @param errorMessage - The error message to check + * @returns true if the error should be silently retried without logging + */ +export function isSilentTransientError(errorMessage: string): boolean { + if (!errorMessage || typeof errorMessage !== "string") { + return false; + } + return SILENT_TRANSIENT_PATTERNS.some((pattern) => pattern.test(errorMessage)); +} + /** * Comprehensive error classification that distinguishes between: * - 'usage-limit': Rate limits, quota exceeded, billing issues → triggers global pause diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index 913dca84c..ae8713fbb 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -21,7 +21,7 @@ import { checkSessionError, type UsageLimitPauser, } from "./usage-limit-detector.js"; -import { isTransientError } from "./transient-error-detector.js"; +import { isTransientError, isSilentTransientError } from "./transient-error-detector.js"; import { withRateLimitRetry } from "./rate-limit-retry.js"; import { computeRecoveryDecision, formatDelay, MAX_RECOVERY_RETRIES } from "./recovery-policy.js"; @@ -714,8 +714,11 @@ export class TriageProcessor { if (decision.shouldRetry) { const attempt = decision.nextState.recoveryRetryCount; const delay = formatDelay(decision.delayMs); - triageLog.warn(`⚡ ${task.id} transient error during triage — retry ${attempt}/${MAX_RECOVERY_RETRIES} in ${delay}: ${err.message}`); - await this.store.logEntry(task.id, `Transient error during specification (retry ${attempt}/${MAX_RECOVERY_RETRIES} in ${delay}): ${err.message}`).catch(() => {}); + // Silent transient errors (e.g., "request was aborted") are noisy — skip logging + if (!isSilentTransientError(err.message)) { + triageLog.warn(`⚡ ${task.id} transient error during triage — retry ${attempt}/${MAX_RECOVERY_RETRIES} in ${delay}: ${err.message}`); + await this.store.logEntry(task.id, `Transient error during specification (retry ${attempt}/${MAX_RECOVERY_RETRIES} in ${delay}): ${err.message}`).catch(() => {}); + } const restoreStatus = task.status === "needs-respecify" ? "needs-respecify" : undefined; await this.store.updateTask(task.id, { status: restoreStatus,