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
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user