diff --git a/src/integrations/harness/providers/claude/claude.ts b/src/integrations/harness/providers/claude/claude.ts index e20185e478..71c340964d 100644 --- a/src/integrations/harness/providers/claude/claude.ts +++ b/src/integrations/harness/providers/claude/claude.ts @@ -16,6 +16,7 @@ import { writeChild, } from "../../core/child"; import { + apiRetryStatusText, askUserQuestionAllowInput, asRecord, assistantMessageId, @@ -175,6 +176,8 @@ type Live = { pendingAssistantBoundary: boolean; manualCompaction: boolean; compactionConfirmed: boolean; + /** An API retry row is showing. */ + apiRetrying: boolean; }; type Resume = { @@ -203,6 +206,7 @@ const tasksByThread = new Map< >(); /** Task-list block key for TaskCreate/TaskUpdate items. */ const CLAUDE_TASKS_KEY = "claude-tasks"; +const API_RETRY_STATUS_KEY = "claude-api-retry"; const cancelledThreads = new Set(); let resolveClaudeBinaryImpl: () => Promise<{ path: string }> = @@ -357,6 +361,7 @@ export async function stopClaudeSession(sessionId: string): Promise { const live = liveByThread.get(sessionId); liveByThread.delete(sessionId); if (live) { + clearApiRetry(live); live.muteUpdates = true; for (const [, pending] of live.approvals) pending.resolve("deny"); live.approvals.clear(); @@ -512,6 +517,7 @@ async function ensureLive(input: HarnessSessionInput): Promise { pendingAssistantBoundary: false, manualCompaction: false, compactionConfirmed: false, + apiRetrying: false, }; liveRef.current = live; @@ -526,6 +532,7 @@ async function ensureLive(input: HarnessSessionInput): Promise { liveByThread.delete(input.sessionId); const current = liveRef.current; if (!current?.muteUpdates) { + if (current) clearApiRetry(current); (current?.onEvent ?? input.onEvent)({ type: "session.ended", code }); } current?.turnFailed?.(new Error("Claude Code exited")); @@ -698,6 +705,15 @@ function handleLine(sessionId: string, live: Live, line: string): void { return; } + const retry = apiRetryStatusText(rec); + if (retry !== undefined) { + live.apiRetrying = true; + live.onEvent({ type: "status", key: API_RETRY_STATUS_KEY, text: retry }); + return; + } + // Any output means the request went through. + if (type !== "system" && type !== "rate_limit_event") clearApiRetry(live); + if (live.manualCompaction && type !== "system" && type !== "result") { return; } @@ -1700,8 +1716,15 @@ function maybeFinishTurn(live: Live): void { ]); } +function clearApiRetry(live: Live): void { + if (!live.apiRetrying) return; + live.apiRetrying = false; + live.onEvent({ type: "status", key: API_RETRY_STATUS_KEY, text: "" }); +} + function finishActiveTurn(live: Live, extraEvents: HarnessEvent[] = []): void { clearAwaitingResume(live); + clearApiRetry(live); live.turnEndPending = false; live.activeTurn = false; for (const event of extraEvents) live.onEvent(event); diff --git a/src/integrations/harness/providers/claude/claudeLive.test.ts b/src/integrations/harness/providers/claude/claudeLive.test.ts index feedeb99b7..3c544801ed 100644 --- a/src/integrations/harness/providers/claude/claudeLive.test.ts +++ b/src/integrations/harness/providers/claude/claudeLive.test.ts @@ -347,6 +347,47 @@ describe("claude streamed tool inputs", () => { }); }); +describe("claude API retries", () => { + it("shows one retry row and clears it once the reply streams", async () => { + const { events, turn } = await startTurn("s1"); + for (const attempt of [1, 2]) { + emit({ + type: "system", + subtype: "api_retry", + session_id: "sess_1", + attempt, + max_retries: 10, + error_status: null, + }); + } + emit({ + type: "assistant", + session_id: "sess_1", + message: { content: [{ type: "text", text: "Back online." }] }, + }); + emit({ type: "result", subtype: "success", session_id: "sess_1" }); + await turn; + + expect( + events.filter( + (event) => event.type === "status" && event.key === "claude-api-retry", + ), + ).toEqual([ + { + type: "status", + key: "claude-api-retry", + text: "Connection error · retrying (1/10)…", + }, + { + type: "status", + key: "claude-api-retry", + text: "Connection error · retrying (2/10)…", + }, + { type: "status", key: "claude-api-retry", text: "" }, + ]); + }); +}); + describe("claude task tools", () => { function emitTaskTool( id: string, diff --git a/src/integrations/harness/providers/claude/claudeProtocol.test.ts b/src/integrations/harness/providers/claude/claudeProtocol.test.ts index 3602d92ce0..3bd23610b7 100644 --- a/src/integrations/harness/providers/claude/claudeProtocol.test.ts +++ b/src/integrations/harness/providers/claude/claudeProtocol.test.ts @@ -4,6 +4,7 @@ import { modelsFromClaudeListModels, } from "./claudeCatalog"; import { + apiRetryStatusText, applyClaudePromptEffortPrefix, applyClaudeTaskTool, askUserQuestionAllowInput, @@ -277,6 +278,40 @@ describe("stream mapping", () => { }); }); +describe("API retries", () => { + it("names the cause and the attempt", () => { + const retry = (extra: Record) => + apiRetryStatusText({ + type: "system", + subtype: "api_retry", + attempt: 7, + max_retries: 10, + ...extra, + }); + expect(retry({ error_status: null })).toBe( + "Connection error · retrying (7/10)…", + ); + expect(retry({ error: "overloaded", error_status: 529 })).toBe( + "API overloaded · retrying (7/10)…", + ); + expect(retry({ error_status: 429 })).toBe( + "Rate limited · retrying (7/10)…", + ); + expect(retry({ error_status: 500 })).toBe( + "API error 500 · retrying (7/10)…", + ); + }); + + it("ignores other system messages and incomplete retries", () => { + expect( + apiRetryStatusText({ type: "system", subtype: "status", attempt: 1 }), + ).toBeUndefined(); + expect( + apiRetryStatusText({ type: "system", subtype: "api_retry", attempt: 1 }), + ).toBeUndefined(); + }); +}); + describe("usage limits", () => { it("reads a refused window and when it resets", () => { expect( diff --git a/src/integrations/harness/providers/claude/claudeProtocol.ts b/src/integrations/harness/providers/claude/claudeProtocol.ts index e744af44a5..de0c7d26c5 100644 --- a/src/integrations/harness/providers/claude/claudeProtocol.ts +++ b/src/integrations/harness/providers/claude/claudeProtocol.ts @@ -477,6 +477,31 @@ export function statusTextFromSystem( return compact ? "Compacted context" : undefined; } +/** + * Claude Code retries a failed API request itself (dropped connection, rate + * limit, overload) and reports each attempt as `system/api_retry`. Without a + * row for it, minutes of retries read as a turn that froze. + */ +export function apiRetryStatusText( + rec: Record, +): string | undefined { + if (stringField(rec, "type") !== "system") return undefined; + if (stringField(rec, "subtype") !== "api_retry") return undefined; + const { attempt, max_retries: max } = rec; + if (typeof attempt !== "number" || typeof max !== "number") return undefined; + return `${apiRetryReason(rec)} · retrying (${attempt}/${max})…`; +} + +function apiRetryReason(rec: Record): string { + const status = typeof rec.error_status === "number" ? rec.error_status : null; + if (rec.no_response) return "No response from the API"; + if (rec.error === "overloaded" || status === 529) return "API overloaded"; + if (rec.error === "rate_limit" || status === 429) return "Rate limited"; + // No HTTP status at all: the request never got a response. + if (status === null) return "Connection error"; + return `API error ${status}`; +} + export function turnStatusFromResult(rec: Record): { status: "completed" | "failed" | "interrupted" | "cancelled"; error?: string;