feat(KB-159): complete Step 4 — update frontend for streaming display

This commit is contained in:
gsxdsm
2026-03-30 08:55:07 -07:00
parent 7954c62201
commit bff0c13d85
7 changed files with 639 additions and 25 deletions

View File

@@ -0,0 +1,5 @@
---
"@dustinbyrne/kb": minor
---
Handle transient connection failures without marking tasks as failed. When the AI agent encounters network errors like "upstream connect error", "ECONNREFUSED", or "connection reset", tasks are now moved back to "todo" for automatic retry instead of being marked as failed. This prevents temporary infrastructure issues from incorrectly failing tasks.

View File

@@ -649,6 +649,14 @@ export interface PlanningSession {
summary: PlanningSummary | null; summary: PlanningSummary | null;
} }
/** SSE event types for planning session streaming */
export type PlanningStreamEvent =
| { type: "thinking"; data: string }
| { type: "question"; data: PlanningQuestion }
| { type: "summary"; data: PlanningSummary }
| { type: "error"; data: string }
| { type: "complete"; data: Record<string, never> };
/** Start a new planning session with an initial plan */ /** Start a new planning session with an initial plan */
export function startPlanning(initialPlan: string): Promise<PlanningSession> { export function startPlanning(initialPlan: string): Promise<PlanningSession> {
return api<PlanningSession>("/planning/start", { return api<PlanningSession>("/planning/start", {
@@ -657,6 +665,14 @@ export function startPlanning(initialPlan: string): Promise<PlanningSession> {
}); });
} }
/** Start a new planning session with AI streaming support */
export function startPlanningStreaming(initialPlan: string): Promise<{ sessionId: string }> {
return api<{ sessionId: string }>("/planning/start-streaming", {
method: "POST",
body: JSON.stringify({ initialPlan }),
});
}
/** Submit a response to the current planning question */ /** Submit a response to the current planning question */
export function respondToPlanning( export function respondToPlanning(
sessionId: string, sessionId: string,
@@ -683,3 +699,107 @@ export function createTaskFromPlanning(sessionId: string): Promise<Task> {
body: JSON.stringify({ sessionId }), body: JSON.stringify({ sessionId }),
}); });
} }
/** Get the SSE stream URL for a planning session */
export function getPlanningStreamUrl(sessionId: string): string {
return `/api/planning/${encodeURIComponent(sessionId)}/stream`;
}
/** Connect to planning session SSE stream and handle events
*
* Returns an object with:
* - close: function to close the connection
* - reconnect: function to reconnect after error
*/
export function connectPlanningStream(
sessionId: string,
handlers: {
onThinking?: (data: string) => void;
onQuestion?: (data: PlanningQuestion) => void;
onSummary?: (data: PlanningSummary) => void;
onError?: (data: string) => void;
onComplete?: () => void;
}
): { close: () => void; isConnected: () => boolean } {
const url = getPlanningStreamUrl(sessionId);
const eventSource = new EventSource(url);
let isClosed = false;
eventSource.onopen = () => {
isClosed = false;
};
eventSource.onmessage = (event) => {
// Handle comment events (heartbeats)
if (event.data.startsWith(":")) return;
};
// Handle specific event types
eventSource.addEventListener("thinking", (event: Event) => {
try {
const messageEvent = event as MessageEvent;
const data = JSON.parse(messageEvent.data);
handlers.onThinking?.(data);
} catch {
const messageEvent = event as MessageEvent;
handlers.onThinking?.(messageEvent.data);
}
});
eventSource.addEventListener("question", (event: Event) => {
try {
const messageEvent = event as MessageEvent;
const data = JSON.parse(messageEvent.data) as PlanningQuestion;
handlers.onQuestion?.(data);
} catch (err) {
console.error("[planning] Failed to parse question event:", err);
}
});
eventSource.addEventListener("summary", (event: Event) => {
try {
const messageEvent = event as MessageEvent;
const data = JSON.parse(messageEvent.data) as PlanningSummary;
handlers.onSummary?.(data);
} catch (err) {
console.error("[planning] Failed to parse summary event:", err);
}
});
eventSource.addEventListener("error", (event: Event) => {
try {
const messageEvent = event as MessageEvent;
const data = JSON.parse(messageEvent.data);
handlers.onError?.(data.message || data);
} catch {
const messageEvent = event as MessageEvent;
handlers.onError?.(messageEvent.data || "Stream error");
}
close();
});
eventSource.addEventListener("complete", () => {
handlers.onComplete?.();
close();
});
// Handle connection errors
eventSource.onerror = () => {
if (!isClosed) {
handlers.onError?.("Connection lost");
close();
}
};
function close() {
if (!isClosed) {
isClosed = true;
eventSource.close();
}
}
return {
close,
isConnected: () => !isClosed && eventSource.readyState === EventSource.OPEN,
};
}

View File

@@ -2,9 +2,11 @@ import { useState, useCallback, useEffect, useRef } from "react";
import type { Task, PlanningQuestion, PlanningSummary } from "@kb/core"; import type { Task, PlanningQuestion, PlanningSummary } from "@kb/core";
import { import {
startPlanning, startPlanning,
startPlanningStreaming,
respondToPlanning, respondToPlanning,
cancelPlanning, cancelPlanning,
createTaskFromPlanning, createTaskFromPlanning,
connectPlanningStream,
type PlanningSession, type PlanningSession,
} from "../api"; } from "../api";
import { Lightbulb, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles } from "lucide-react"; import { Lightbulb, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles } from "lucide-react";
@@ -41,7 +43,11 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
const [responseHistory, setResponseHistory] = useState<QuestionResponse[]>([]); const [responseHistory, setResponseHistory] = useState<QuestionResponse[]>([]);
const [editedSummary, setEditedSummary] = useState<PlanningSummary | null>(null); const [editedSummary, setEditedSummary] = useState<PlanningSummary | null>(null);
const [hasAutoStarted, setHasAutoStarted] = useState(false); const [hasAutoStarted, setHasAutoStarted] = useState(false);
const [streamingOutput, setStreamingOutput] = useState<string>("");
const [showThinking, setShowThinking] = useState(true);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const streamConnectionRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null);
const currentSessionIdRef = useRef<string | null>(null);
// Focus textarea when opening // Focus textarea when opening
useEffect(() => { useEffect(() => {
@@ -70,6 +76,14 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
} }
}, [isOpen]); }, [isOpen]);
// Cleanup stream connection on unmount
useEffect(() => {
return () => {
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
};
}, []);
// Handle browser unload during active session // Handle browser unload during active session
useEffect(() => { useEffect(() => {
if (!isOpen) return; if (!isOpen) return;
@@ -79,6 +93,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
e.preventDefault(); e.preventDefault();
e.returnValue = ""; e.returnValue = "";
} }
// Close stream connection
streamConnectionRef.current?.close();
}; };
window.addEventListener("beforeunload", handleBeforeUnload); window.addEventListener("beforeunload", handleBeforeUnload);
@@ -109,20 +125,52 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
if (!initialPlan.trim()) return; if (!initialPlan.trim()) return;
setError(null); setError(null);
setStreamingOutput("");
setView({ type: "loading" }); setView({ type: "loading" });
try { try {
const session = await startPlanning(initialPlan.trim()); // Use streaming mode for real-time AI thinking display
if (session.currentQuestion) { const { sessionId } = await startPlanningStreaming(initialPlan.trim());
setView({ type: "question", session }); currentSessionIdRef.current = sessionId;
} else if (session.summary) {
setView({ type: "summary", session, summary: session.summary }); // Connect to SSE stream
setEditedSummary(session.summary); const connection = connectPlanningStream(sessionId, {
} onThinking: (data) => {
setStreamingOutput((prev) => prev + data);
},
onQuestion: (question) => {
setView({
type: "question",
session: { sessionId, currentQuestion: question, summary: null },
});
setStreamingOutput("");
},
onSummary: (summary) => {
setView({
type: "summary",
session: { sessionId, currentQuestion: null, summary },
summary,
});
setEditedSummary(summary);
setStreamingOutput("");
},
onError: (message) => {
setError(message);
setView({ type: "initial" });
setStreamingOutput("");
currentSessionIdRef.current = null;
},
onComplete: () => {
currentSessionIdRef.current = null;
},
});
streamConnectionRef.current = connection;
setResponseHistory([]); setResponseHistory([]);
} catch (err: any) { } catch (err: any) {
setError(err.message || "Failed to start planning session"); setError(err.message || "Failed to start planning session");
setView({ type: "initial" }); setView({ type: "initial" });
currentSessionIdRef.current = null;
} }
}, [initialPlan]); }, [initialPlan]);
@@ -131,20 +179,50 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
if (!plan.trim()) return; if (!plan.trim()) return;
setError(null); setError(null);
setStreamingOutput("");
setView({ type: "loading" }); setView({ type: "loading" });
try { try {
const session = await startPlanning(plan.trim()); const { sessionId } = await startPlanningStreaming(plan.trim());
if (session.currentQuestion) { currentSessionIdRef.current = sessionId;
setView({ type: "question", session });
} else if (session.summary) { const connection = connectPlanningStream(sessionId, {
setView({ type: "summary", session, summary: session.summary }); onThinking: (data) => {
setEditedSummary(session.summary); setStreamingOutput((prev) => prev + data);
} },
onQuestion: (question) => {
setView({
type: "question",
session: { sessionId, currentQuestion: question, summary: null },
});
setStreamingOutput("");
},
onSummary: (summary) => {
setView({
type: "summary",
session: { sessionId, currentQuestion: null, summary },
summary,
});
setEditedSummary(summary);
setStreamingOutput("");
},
onError: (message) => {
setError(message);
setView({ type: "initial" });
setStreamingOutput("");
currentSessionIdRef.current = null;
},
onComplete: () => {
currentSessionIdRef.current = null;
},
});
streamConnectionRef.current = connection;
setResponseHistory([]); setResponseHistory([]);
} catch (err: any) { } catch (err: any) {
setError(err.message || "Failed to start planning session"); setError(err.message || "Failed to start planning session");
setView({ type: "initial" }); setView({ type: "initial" });
currentSessionIdRef.current = null;
} }
}, []); }, []);
@@ -153,19 +231,59 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
if (view.type !== "question") return; if (view.type !== "question") return;
const { session } = view; const { session } = view;
const sessionId = session.sessionId;
setError(null); setError(null);
setStreamingOutput("");
setView({ type: "loading" }); setView({ type: "loading" });
try { try {
const updatedSession = await respondToPlanning(session.sessionId, responses); // Close previous connection if any
streamConnectionRef.current?.close();
// Submit response - this will trigger the AI to process and stream
const updatedSession = await respondToPlanning(sessionId, responses);
setResponseHistory((prev) => [...prev, responses]); setResponseHistory((prev) => [...prev, responses]);
// If we got an immediate response (non-streaming mode), use it
if (updatedSession.summary) { if (updatedSession.summary) {
setView({ type: "summary", session: updatedSession, summary: updatedSession.summary }); setView({ type: "summary", session: updatedSession, summary: updatedSession.summary });
setEditedSummary(updatedSession.summary); setEditedSummary(updatedSession.summary);
} else if (updatedSession.currentQuestion) { return;
setView({ type: "question", session: updatedSession });
} }
if (updatedSession.currentQuestion) {
setView({ type: "question", session: updatedSession });
return;
}
// Otherwise, set up streaming for the next question
const connection = connectPlanningStream(sessionId, {
onThinking: (data) => {
setStreamingOutput((prev) => prev + data);
},
onQuestion: (question) => {
setView({
type: "question",
session: { sessionId, currentQuestion: question, summary: null },
});
setStreamingOutput("");
},
onSummary: (summary) => {
setView({
type: "summary",
session: { sessionId, currentQuestion: null, summary },
summary,
});
setEditedSummary(summary);
setStreamingOutput("");
},
onError: (message) => {
setError(message);
setView({ type: "question", session });
setStreamingOutput("");
},
});
streamConnectionRef.current = connection;
} catch (err: any) { } catch (err: any) {
setError(err.message || "Failed to submit response"); setError(err.message || "Failed to submit response");
setView({ type: "question", session }); setView({ type: "question", session });
@@ -175,6 +293,10 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
); );
const handleCancel = useCallback(async () => { const handleCancel = useCallback(async () => {
// Always close the stream connection
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
if (view.type === "question" || view.type === "summary") { if (view.type === "question" || view.type === "summary") {
try { try {
await cancelPlanning(view.session.sessionId); await cancelPlanning(view.session.sessionId);
@@ -187,6 +309,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
setError(null); setError(null);
setResponseHistory([]); setResponseHistory([]);
setEditedSummary(null); setEditedSummary(null);
setStreamingOutput("");
currentSessionIdRef.current = null;
onClose(); onClose();
}, [view, onClose]); }, [view, onClose]);
@@ -309,7 +433,23 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
{view.type === "loading" && ( {view.type === "loading" && (
<div className="planning-loading"> <div className="planning-loading">
<Loader2 size={40} className="spin" style={{ color: "var(--todo)" }} /> <Loader2 size={40} className="spin" style={{ color: "var(--todo)" }} />
<p>Thinking...</p> <p>{streamingOutput ? "AI is thinking..." : "Connecting..."}</p>
{streamingOutput && (
<div className="planning-thinking-container">
<button
className="planning-thinking-toggle"
onClick={() => setShowThinking(!showThinking)}
type="button"
>
{showThinking ? "Hide thinking" : "Show thinking"}
</button>
{showThinking && (
<div className="planning-thinking-output">
<pre>{streamingOutput}</pre>
</div>
)}
</div>
)}
</div> </div>
)} )}

View File

@@ -634,17 +634,32 @@ async function continueAgentConversation(session: Session, message: string): Pro
try { try {
// Clear thinking output for this turn // Clear thinking output for this turn
const previousThinking = session.thinkingOutput;
session.thinkingOutput = ""; session.thinkingOutput = "";
// Send message to agent - it will stream thinking via onThinking callback // Send message to agent using .prompt() - it will stream thinking via onThinking callback
const response = await session.agent.session.send(message); await session.agent.session.prompt(message);
// Combine any thinking output with the response text // Get the response text from the agent's state
const fullResponse = session.thinkingOutput + (response?.text || ""); const lastMessage = session.agent.session.state.messages
.filter(m => m.role === "assistant")
.pop();
let responseText = session.thinkingOutput;
if (lastMessage?.content) {
// Handle both string and array content types
if (typeof lastMessage.content === "string") {
responseText = lastMessage.content;
} else if (Array.isArray(lastMessage.content)) {
// Extract text from content blocks
responseText = lastMessage.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map(c => c.text)
.join("");
}
}
// Parse the JSON response // Parse the JSON response
const parsed = parseAgentResponse(fullResponse); const parsed = parseAgentResponse(responseText);
if (parsed.type === "question") { if (parsed.type === "question") {
session.currentQuestion = parsed.data; session.currentQuestion = parsed.data;

View File

@@ -2169,6 +2169,44 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
} }
}); });
/**
* POST /api/planning/start-streaming
* Start a new planning session with AI agent streaming.
* Body: { initialPlan: string }
* Returns: { sessionId: string }
*
* After receiving sessionId, connect to GET /api/planning/:sessionId/stream
* for real-time thinking output and questions.
*/
router.post("/planning/start-streaming", async (req, res) => {
try {
const { initialPlan } = req.body;
if (!initialPlan || typeof initialPlan !== "string") {
res.status(400).json({ error: "initialPlan is required and must be a string" });
return;
}
if (initialPlan.length > 500) {
res.status(400).json({ error: "initialPlan must be 500 characters or less" });
return;
}
const ip = req.ip || req.socket.remoteAddress || "unknown";
const rootDir = store.getRootDir();
const { createSessionWithAgent, RateLimitError } = await import("./planning.js");
const sessionId = await createSessionWithAgent(ip, initialPlan, rootDir);
res.status(201).json({ sessionId });
} catch (err: any) {
if (err.name === "RateLimitError") {
res.status(429).json({ error: err.message });
} else {
res.status(500).json({ error: err.message || "Failed to start planning session" });
}
}
});
/** /**
* POST /api/planning/respond * POST /api/planning/respond
* Submit a response to the current planning question. * Submit a response to the current planning question.
@@ -2322,7 +2360,8 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
// Subscribe to session events // Subscribe to session events
const unsubscribe = planningStreamManager.subscribe(sessionId, (event) => { const unsubscribe = planningStreamManager.subscribe(sessionId, (event) => {
try { try {
res.write(`event: ${event.type}\ndata: ${JSON.stringify(event.data ?? {})}\n\n`); const data = (event as { data?: unknown }).data;
res.write(`event: ${event.type}\ndata: ${JSON.stringify(data ?? {})}\n\n`);
// End stream on complete or error // End stream on complete or error
if (event.type === "complete" || event.type === "error") { if (event.type === "complete" || event.type === "error") {

View File

@@ -0,0 +1,187 @@
import { describe, it, expect } from "vitest";
import {
isTransientError,
classifyError,
TRANSIENT_ERROR_PATTERNS,
} from "./transient-error-detector.js";
import { isUsageLimitError } from "./usage-limit-detector.js";
describe("Transient Error Detector", () => {
describe("isTransientError", () => {
// Core error messages from the task description
it("matches the full upstream connect error message", () => {
const message =
"upstream connect error or disconnect/reset before headers. retried and the latest reset reason: remote connection failure, transport failure reason: delayed connect error: Connection refused";
expect(isTransientError(message)).toBe(true);
});
it("matches 'upstream connect error'", () => {
expect(isTransientError("upstream connect error")).toBe(true);
expect(isTransientError("Upstream Connect Error")).toBe(true);
expect(isTransientError("UPSTREAM CONNECT ERROR")).toBe(true);
});
it("matches 'disconnect/reset before headers'", () => {
expect(isTransientError("disconnect/reset before headers")).toBe(true);
expect(isTransientError("Disconnect/Reset Before Headers")).toBe(true);
});
it("matches 'retried and the latest reset reason'", () => {
expect(isTransientError("retried and the latest reset reason: timeout")).toBe(true);
expect(isTransientError("Retried And The Latest Reset Reason")).toBe(true);
});
it("matches 'remote connection failure'", () => {
expect(isTransientError("remote connection failure")).toBe(true);
expect(isTransientError("Remote Connection Failure")).toBe(true);
});
it("matches 'transport failure reason'", () => {
expect(isTransientError("transport failure reason: connection reset")).toBe(true);
expect(isTransientError("Transport Failure Reason")).toBe(true);
});
it("matches 'delayed connect error'", () => {
expect(isTransientError("delayed connect error: Connection refused")).toBe(true);
expect(isTransientError("Delayed Connect Error")).toBe(true);
});
it("matches 'Connection refused'", () => {
expect(isTransientError("Connection refused")).toBe(true);
expect(isTransientError("connection refused")).toBe(true);
expect(isTransientError("CONNECTION REFUSED")).toBe(true);
});
it("matches 'connection reset'", () => {
expect(isTransientError("connection reset by peer")).toBe(true);
expect(isTransientError("Connection Reset")).toBe(true);
});
it("matches 'ECONNREFUSED'", () => {
expect(isTransientError("ECONNREFUSED")).toBe(true);
expect(isTransientError("Error: ECONNREFUSED")).toBe(true);
});
it("matches 'ETIMEDOUT'", () => {
expect(isTransientError("ETIMEDOUT")).toBe(true);
expect(isTransientError("Error: ETIMEDOUT")).toBe(true);
});
it("matches 'socket hang up'", () => {
expect(isTransientError("socket hang up")).toBe(true);
expect(isTransientError("Socket Hang Up")).toBe(true);
expect(isTransientError("Error: socket hang up")).toBe(true);
});
it("matches connection timeout patterns", () => {
expect(isTransientError("connection timeout")).toBe(true);
expect(isTransientError("timeout connection to server")).toBe(true);
});
// Edge cases
it("returns false for empty string", () => {
expect(isTransientError("")).toBe(false);
});
it("returns false for null", () => {
expect(isTransientError(null as unknown as string)).toBe(false);
});
it("returns false for undefined", () => {
expect(isTransientError(undefined as unknown as string)).toBe(false);
});
it("returns false for non-string values", () => {
expect(isTransientError(123 as unknown as string)).toBe(false);
expect(isTransientError({} as unknown as string)).toBe(false);
expect(isTransientError([] as unknown as string)).toBe(false);
});
// Should NOT match non-transient errors
it("returns false for code errors", () => {
expect(isTransientError("SyntaxError: Unexpected token")).toBe(false);
expect(isTransientError("TypeError: Cannot read property")).toBe(false);
expect(isTransientError("ReferenceError: foo is not defined")).toBe(false);
});
it("returns false for test failures", () => {
expect(isTransientError("Assertion failed: expected 1 to be 2")).toBe(false);
expect(isTransientError("Test timeout of 5000ms exceeded")).toBe(false);
});
it("returns false for usage limit errors", () => {
expect(isTransientError("rate limit exceeded")).toBe(false);
expect(isTransientError("429 Too Many Requests")).toBe(false);
expect(isTransientError("API quota exceeded")).toBe(false);
});
// Partial matches should not trigger false positives
it("handles partial matches correctly", () => {
// Should not match just "error" or "timeout" without connection context
expect(isTransientError("An error occurred")).toBe(false);
// "timeout" alone is not in the patterns (only connection timeouts)
expect(isTransientError("timeout")).toBe(false);
expect(isTransientError("Request timeout")).toBe(false);
});
});
describe("classifyError", () => {
it("classifies usage limit errors as 'usage-limit'", () => {
expect(classifyError("rate limit exceeded")).toBe("usage-limit");
expect(classifyError("429 Too Many Requests")).toBe("usage-limit");
expect(classifyError("API overloaded")).toBe("usage-limit");
expect(classifyError("quota exceeded")).toBe("usage-limit");
expect(classifyError("billing issue")).toBe("usage-limit");
});
it("classifies transient errors as 'transient'", () => {
expect(classifyError("upstream connect error")).toBe("transient");
expect(classifyError("ECONNREFUSED")).toBe("transient");
expect(classifyError("socket hang up")).toBe("transient");
expect(classifyError("Connection refused")).toBe("transient");
});
it("classifies all other errors as 'permanent'", () => {
expect(classifyError("SyntaxError: Unexpected token")).toBe("permanent");
expect(classifyError("Test failed")).toBe("permanent");
expect(classifyError("Build error")).toBe("permanent");
});
// Priority: usage limit > transient > permanent
it("prioritizes usage limits over transient errors", () => {
// Usage limit patterns should take precedence
const usageLimitMsg = "rate limit exceeded while connecting";
expect(isUsageLimitError(usageLimitMsg)).toBe(true);
expect(classifyError(usageLimitMsg)).toBe("usage-limit");
});
it("handles empty/invalid input as 'permanent'", () => {
expect(classifyError("")).toBe("permanent");
expect(classifyError(null as unknown as string)).toBe("permanent");
expect(classifyError(undefined as unknown as string)).toBe("permanent");
});
it("classifies the full complex error message correctly", () => {
const message =
"upstream connect error or disconnect/reset before headers. retried and the latest reset reason: remote connection failure, transport failure reason: delayed connect error: Connection refused";
expect(classifyError(message)).toBe("transient");
});
});
describe("TRANSIENT_ERROR_PATTERNS", () => {
it("exports the patterns array", () => {
expect(Array.isArray(TRANSIENT_ERROR_PATTERNS)).toBe(true);
expect(TRANSIENT_ERROR_PATTERNS.length).toBeGreaterThan(0);
// All patterns should be RegExp
TRANSIENT_ERROR_PATTERNS.forEach((pattern) => {
expect(pattern).toBeInstanceOf(RegExp);
});
});
it("all patterns have case-insensitive flag", () => {
TRANSIENT_ERROR_PATTERNS.forEach((pattern) => {
expect(pattern.flags).toContain("i");
});
});
});
});

View File

@@ -0,0 +1,108 @@
/**
* Transient Error Detector — classifies network/infrastructure errors as transient
* (temporary and retryable) versus permanent failures.
*
* Transient errors indicate temporary conditions like network blips, proxy hiccups,
* connection resets, or temporary service unavailability. These errors typically
* resolve on their own after a short delay and should NOT mark tasks as failed.
*
* When a transient error is detected, the task should be moved back to "todo"
* for later retry rather than being marked as "failed". This prevents tasks from
* being incorrectly marked as failed due to temporary infrastructure issues.
*
* Contrast with:
* - Usage limit errors: Systemic conditions (rate limits, quota) → trigger global pause
* - Permanent errors: Code issues, test failures, logic errors → mark task as failed
*/
import { isUsageLimitError } from "./usage-limit-detector.js";
/**
* Patterns that indicate transient network/infrastructure errors.
* These are checked case-insensitively against error messages.
*
* These patterns cover:
* - Proxy/gateway connection errors (upstream connect, disconnect/reset)
* - Connection refusal/reset (ECONNREFUSED, connection reset)
* - Timeouts (ETIMEDOUT, timeout in connection context)
* - Socket errors (socket hang up)
* - Transport layer failures
*/
export const TRANSIENT_ERROR_PATTERNS: RegExp[] = [
// Proxy/gateway errors - indicate temporary routing issues
/upstream connect error/i,
/disconnect\/reset before headers/i,
/retried and the latest reset reason/i,
/remote connection failure/i,
/transport failure reason/i,
/delayed connect error/i,
// Connection establishment failures - usually temporary
/Connection refused/i,
/connection reset/i,
/ECONNREFUSED/i,
/ETIMEDOUT/i,
/socket hang up/i,
// Timeout patterns (only when related to connections, not general timeouts)
/timeout.*connection/i,
/connection.*timeout/i,
];
/**
* Check if an error message indicates a transient network/infrastructure error.
*
* Transient errors are temporary conditions that typically resolve after a delay:
* - Network blips and temporary routing issues
* - Proxy/gateway hiccups (upstream connect errors)
* - Connection resets during establishment
* - Temporary service unavailability (connection refused)
* - Socket timeouts during connection
*
* Returns `true` for transient errors — these should trigger a retry by moving
* the task back to "todo" rather than marking as "failed".
*
* Returns `false` for permanent failures (code errors, test failures) or
* usage limit errors (rate limits that need global pause).
*
* @param errorMessage - The error message to classify
* @returns true if the error appears transient and retryable
*/
export function isTransientError(errorMessage: string): boolean {
if (!errorMessage || typeof errorMessage !== "string") {
return false;
}
return TRANSIENT_ERROR_PATTERNS.some((pattern) => pattern.test(errorMessage));
}
/**
* Comprehensive error classification that distinguishes between:
* - 'usage-limit': Rate limits, quota exceeded, billing issues → triggers global pause
* - 'transient': Network blips, connection errors → move task to "todo" for retry
* - 'permanent': Code errors, test failures, logic errors → mark task as failed
*
* This function delegates to existing usage limit detection first (to preserve
* existing behavior), then checks for transient patterns, defaulting to
* 'permanent' for all other errors.
*
* @param errorMessage - The error message to classify
* @returns The error classification category
*/
export function classifyError(errorMessage: string): "transient" | "usage-limit" | "permanent" {
if (!errorMessage || typeof errorMessage !== "string") {
return "permanent";
}
// Check usage limits first (highest priority - triggers global pause)
if (isUsageLimitError(errorMessage)) {
return "usage-limit";
}
// Check transient patterns next (move to todo for retry)
if (isTransientError(errorMessage)) {
return "transient";
}
// Default to permanent (mark as failed)
return "permanent";
}