Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions src/integrations/harness/providers/claude/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
writeChild,
} from "../../core/child";
import {
apiRetryStatusText,
askUserQuestionAllowInput,
asRecord,
assistantMessageId,
Expand Down Expand Up @@ -175,6 +176,8 @@ type Live = {
pendingAssistantBoundary: boolean;
manualCompaction: boolean;
compactionConfirmed: boolean;
/** An API retry row is showing. */
apiRetrying: boolean;
};

type Resume = {
Expand Down Expand Up @@ -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<string>();

let resolveClaudeBinaryImpl: () => Promise<{ path: string }> =
Expand Down Expand Up @@ -357,6 +361,7 @@ export async function stopClaudeSession(sessionId: string): Promise<void> {
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();
Expand Down Expand Up @@ -512,6 +517,7 @@ async function ensureLive(input: HarnessSessionInput): Promise<Live> {
pendingAssistantBoundary: false,
manualCompaction: false,
compactionConfirmed: false,
apiRetrying: false,
};
liveRef.current = live;

Expand All @@ -526,6 +532,7 @@ async function ensureLive(input: HarnessSessionInput): Promise<Live> {
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"));
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
Expand Down
41 changes: 41 additions & 0 deletions src/integrations/harness/providers/claude/claudeLive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
35 changes: 35 additions & 0 deletions src/integrations/harness/providers/claude/claudeProtocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
modelsFromClaudeListModels,
} from "./claudeCatalog";
import {
apiRetryStatusText,
applyClaudePromptEffortPrefix,
applyClaudeTaskTool,
askUserQuestionAllowInput,
Expand Down Expand Up @@ -277,6 +278,40 @@ describe("stream mapping", () => {
});
});

describe("API retries", () => {
it("names the cause and the attempt", () => {
const retry = (extra: Record<string, unknown>) =>
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(
Expand Down
25 changes: 25 additions & 0 deletions src/integrations/harness/providers/claude/claudeProtocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, unknown>,
): 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, unknown>): 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<string, unknown>): {
status: "completed" | "failed" | "interrupted" | "cancelled";
error?: string;
Expand Down
Loading