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:
gsxdsm
2026-04-04 22:03:42 -07:00
parent ba90bad3dc
commit 64f617e0eb
4 changed files with 82 additions and 6 deletions

View File

@@ -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,

View File

@@ -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);
});
});
});

View File

@@ -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

View File

@@ -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,