Repository navigation
[Fix] Persistently failing stream is retried forever #2111
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -153,6 +153,8 @@ | |
| } from "./run-state/runStateDivergence" | ||
|
|
||
| const MAX_EXPONENTIAL_BACKOFF_SECONDS = 600 // 10 minutes | ||
| // Consecutive mid-stream failures retried automatically before the user is asked. | ||
| const MAX_MID_STREAM_AUTO_RETRIES = 5 | ||
| const DEFAULT_USAGE_COLLECTION_TIMEOUT_MS = 5000 // 5 seconds | ||
| // Upper bound on awaiting lazily loaded model metadata. Some model-catalog | ||
| // fetchers (e.g. OpenRouter's bare axios GET) have no request timeout, so a | ||
|
|
@@ -3864,6 +3866,9 @@ | |
| userContent: Anthropic.Messages.ContentBlockParam[] | ||
| includeFileDetails: boolean | ||
| retryAttempt?: number | ||
| // Automatic retries of a mid-stream failure. Kept apart from `retryAttempt`, which also | ||
| // tells shouldAddUserMessageToHistory that the user message is already in history. | ||
| midStreamAutoRetries?: number | ||
| userMessageWasRemoved?: boolean // Track if user message was removed due to empty response | ||
| } | ||
|
|
||
|
|
@@ -4671,33 +4676,58 @@ | |
| }) | ||
| continue | ||
| } else { | ||
| const stateForBackoff = await this.providerRef.deref()?.getState() | ||
|
Check warning on line 4679 in src/core/task/Task.ts
|
||
| const midStreamRetryAttempt = currentItem.midStreamAutoRetries ?? 0 | ||
|
|
||
| // Without auto-approval, or once automatic retries are exhausted, a | ||
| // persistently failing stream (e.g. an upstream idle timeout) would | ||
| // otherwise be re-requested forever. Let the user decide instead. | ||
| if ( | ||
| !stateForBackoff?.autoApprovalEnabled || | ||
|
Check warning on line 4686 in src/core/task/Task.ts
|
||
| midStreamRetryAttempt >= MAX_MID_STREAM_AUTO_RETRIES | ||
| ) { | ||
| const { response } = await this.ask("api_req_failed", rawErrorMessage) | ||
|
|
||
| if (response !== "yesButtonClicked") { | ||
| throw new Error("API request failed") | ||
| } | ||
|
|
||
| await this.say("api_req_retried") | ||
|
Check warning on line 4695 in src/core/task/Task.ts
|
||
| // The user message is already in history, so keep the history attempt positive | ||
| // and only reset the automatic-retry count. | ||
| stack.push({ | ||
| userContent: currentUserContent, | ||
| includeFileDetails: false, | ||
|
Check warning on line 4700 in src/core/task/Task.ts
|
||
| retryAttempt: (currentItem.retryAttempt ?? 0) + 1, | ||
|
Check warning on line 4701 in src/core/task/Task.ts
|
||
| midStreamAutoRetries: 0, | ||
| }) | ||
|
Comment on lines
+4698
to
+4703
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Keep the history attempt separate from the automatic retry count. On a confirmed retry, this stack item reuses 🧰 Tools🪛 GitHub Check: mutation-diff[warning] 4709-4709: Mutation test advisory 🤖 Prompt for AI AgentsSource: Path instructions |
||
| continue | ||
| } | ||
|
|
||
| // Stream failed - log the error and retry with the same content | ||
| // The existing rate limiting will prevent rapid retries | ||
| console.error( | ||
| `[Task#${this.taskId}.${this.instanceId}] Stream failed, will retry: ${streamingFailedMessage}`, | ||
| ) | ||
|
|
||
| // Apply exponential backoff similar to first-chunk errors when auto-resubmit is enabled | ||
| const stateForBackoff = await this.providerRef.deref()?.getState() | ||
| if (stateForBackoff?.autoApprovalEnabled) { | ||
| await this.backoffAndAnnounce(currentItem.retryAttempt ?? 0, error) | ||
| // Apply exponential backoff similar to first-chunk errors | ||
| await this.backoffAndAnnounce(midStreamRetryAttempt, error) | ||
|
|
||
| // Check if task was aborted during the backoff | ||
| if (this.abort) { | ||
| console.log( | ||
| `[Task#${this.taskId}.${this.instanceId}] Task aborted during mid-stream retry backoff`, | ||
| ) | ||
| this.setAbortReason("user_cancelled") | ||
| await this.abortTask() | ||
| break | ||
| } | ||
| // Check if task was aborted during the backoff | ||
| if (this.abort) { | ||
| console.log( | ||
| `[Task#${this.taskId}.${this.instanceId}] Task aborted during mid-stream retry backoff`, | ||
|
Check warning on line 4718 in src/core/task/Task.ts
|
||
| ) | ||
| this.setAbortReason("user_cancelled") | ||
|
Check warning on line 4720 in src/core/task/Task.ts
|
||
| await this.abortTask() | ||
| break | ||
| } | ||
|
|
||
| // Push the same content back onto the stack to retry, incrementing the retry attempt counter | ||
| stack.push({ | ||
| userContent: currentUserContent, | ||
| includeFileDetails: false, | ||
| retryAttempt: (currentItem.retryAttempt ?? 0) + 1, | ||
| midStreamAutoRetries: midStreamRetryAttempt + 1, | ||
| }) | ||
|
|
||
| // Continue to retry the request | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -475,6 +475,8 @@ describe("Cline", () => { | |
| apiConfiguration: mockApiConfig, | ||
| autoApprovalEnabled: false, | ||
| }) | ||
| // A persistent mid-stream failure asks the user; these tests confirm the retry. | ||
| vi.spyOn(task, "ask").mockResolvedValue({ response: "yesButtonClicked" } satisfies TaskAskResult) | ||
| vi.spyOn(task.diffViewProvider, "reset").mockResolvedValue(undefined) | ||
| return task | ||
| } | ||
|
|
@@ -621,6 +623,89 @@ describe("Cline", () => { | |
| }) | ||
| }) | ||
|
|
||
| describe("persistent mid-stream failure", () => { | ||
| async function* idleTimeoutStream(): AsyncGenerator<ApiStreamChunk> { | ||
| yield { type: "text", text: "partial answer" } | ||
| throw new Error("Provider ended the request: Upstream idle timeout exceeded.") | ||
| } | ||
|
|
||
| // 5 is MAX_MID_STREAM_AUTO_RETRIES: the first attempt plus 5 automatic retries. | ||
| it.each([ | ||
| { autoApprovalEnabled: true, expectedAttempts: 6, expectedBackoffAttempts: [0, 1, 2, 3, 4] }, | ||
| { autoApprovalEnabled: false, expectedAttempts: 1, expectedBackoffAttempts: [] }, | ||
| ])( | ||
| "asks the user instead of retrying forever (autoApprovalEnabled=$autoApprovalEnabled)", | ||
| async ({ autoApprovalEnabled, expectedAttempts, expectedBackoffAttempts }) => { | ||
| vi.spyOn(mockProvider, "getState").mockResolvedValue( | ||
| providerStateWith({ autoApprovalEnabled, requestDelaySeconds: 0 }), | ||
| ) | ||
| const task = new Task({ | ||
| provider: mockProvider, | ||
| apiConfiguration: mockApiConfig, | ||
| task: "test task", | ||
| startTask: false, | ||
| }) | ||
| vi.spyOn(task.diffViewProvider, "reset").mockResolvedValue(undefined) | ||
| vi.spyOn(getTaskTestAccess(task), "safeEnsureModelFetched").mockResolvedValue(stubModelInfo) | ||
| vi.spyOn(getTaskTestAccess(task), "presentAssistantMessageSafe").mockImplementation(() => {}) | ||
| const backoffSpy = vi.spyOn(getTaskTestAccess(task), "backoffAndAnnounce").mockResolvedValue(undefined) | ||
| const askSpy = vi | ||
| .spyOn(task, "ask") | ||
| .mockResolvedValue({ response: "noButtonClicked" } satisfies TaskAskResult) | ||
| const attemptApiRequestSpy = vi | ||
| .spyOn(task, "attemptApiRequest") | ||
| .mockImplementation(() => idleTimeoutStream()) | ||
|
|
||
| // Declining ends the turn: the "API request failed" error is handled inside the loop, | ||
| // so the call resolves rather than rejecting. | ||
| await expect(task.recursivelyMakeClineRequests([{ type: "text", text: "long request" }])).resolves.toBe( | ||
| true, | ||
| ) | ||
|
|
||
| expect(attemptApiRequestSpy).toHaveBeenCalledTimes(expectedAttempts) | ||
| expect(backoffSpy.mock.calls.map(([attempt]) => attempt)).toEqual(expectedBackoffAttempts) | ||
| expect(askSpy).toHaveBeenCalledOnce() | ||
| expect(askSpy).toHaveBeenCalledWith("api_req_failed", expect.stringContaining("idle timeout")) | ||
|
Comment on lines
+665
to
+668
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Test approval after the automatic retry cap. This case declines at the cap, so it cannot detect a retry that retains the exhausted
|
||
| }, | ||
| ) | ||
|
|
||
| it("does not add the user message to history again when the user confirms a retry", async () => { | ||
| vi.spyOn(mockProvider, "getState").mockResolvedValue( | ||
| providerStateWith({ autoApprovalEnabled: false, requestDelaySeconds: 0 }), | ||
| ) | ||
| const task = new Task({ | ||
| provider: mockProvider, | ||
| apiConfiguration: mockApiConfig, | ||
| task: "test task", | ||
| startTask: false, | ||
| }) | ||
| vi.spyOn(task.diffViewProvider, "reset").mockResolvedValue(undefined) | ||
| vi.spyOn(getTaskTestAccess(task), "safeEnsureModelFetched").mockResolvedValue(stubModelInfo) | ||
| vi.spyOn(getTaskTestAccess(task), "presentAssistantMessageSafe").mockImplementation(() => {}) | ||
| const historySpy = vi | ||
| .spyOn(getTaskTestAccess(task), "addToApiConversationHistory") | ||
| .mockResolvedValue(undefined) | ||
| const askSpy = vi | ||
| .spyOn(task, "ask") | ||
| .mockResolvedValueOnce({ response: "yesButtonClicked" } satisfies TaskAskResult) | ||
| .mockResolvedValue({ response: "noButtonClicked" } satisfies TaskAskResult) | ||
| const attemptApiRequestSpy = vi | ||
| .spyOn(task, "attemptApiRequest") | ||
| .mockImplementation(() => idleTimeoutStream()) | ||
|
|
||
| await expect(task.recursivelyMakeClineRequests([{ type: "text", text: "long request" }])).resolves.toBe( | ||
| true, | ||
| ) | ||
|
|
||
| expect(askSpy).toHaveBeenCalledTimes(2) | ||
| expect(attemptApiRequestSpy).toHaveBeenCalledTimes(2) | ||
| const userMessages = historySpy.mock.calls.filter( | ||
| ([message]) => (message as { role: string }).role === "user", | ||
| ) | ||
| expect(userMessages).toHaveLength(1) | ||
| }) | ||
| }) | ||
|
|
||
| describe("native tool-call request isolation", () => { | ||
| it("derives readiness only from a closed, idle, fully paired tool turn", () => { | ||
| const task = new Task({ | ||
|
|
@@ -1163,6 +1248,8 @@ describe("Cline", () => { | |
| startTask: false, | ||
| }) | ||
|
|
||
| // Mid-stream failures ask the user; confirm the retry. | ||
| vi.spyOn(task, "ask").mockResolvedValue({ response: "yesButtonClicked" } satisfies TaskAskResult) | ||
| vi.spyOn(task.diffViewProvider, "reset").mockResolvedValue(undefined) | ||
| vi.spyOn(getTaskTestAccess(task), "safeEnsureModelFetched").mockResolvedValue(stubModelInfo) | ||
| vi.spyOn(getTaskTestAccess(task), "presentAssistantMessageSafe").mockImplementation(() => {}) | ||
|
|
@@ -1211,6 +1298,8 @@ describe("Cline", () => { | |
| startTask: false, | ||
| }) | ||
|
|
||
| // Mid-stream failures ask the user; confirm the retry. | ||
| vi.spyOn(task, "ask").mockResolvedValue({ response: "yesButtonClicked" } satisfies TaskAskResult) | ||
| vi.spyOn(task.diffViewProvider, "reset").mockResolvedValue(undefined) | ||
| vi.spyOn(getTaskTestAccess(task), "safeEnsureModelFetched").mockResolvedValue(stubModelInfo) | ||
| vi.spyOn(getTaskTestAccess(task), "presentAssistantMessageSafe").mockImplementation(() => {}) | ||
|
|
@@ -1303,6 +1392,8 @@ describe("Cline", () => { | |
| startTask: false, | ||
| }) | ||
|
|
||
| // Mid-stream failures ask the user; confirm the retry. | ||
| vi.spyOn(task, "ask").mockResolvedValue({ response: "yesButtonClicked" } satisfies TaskAskResult) | ||
| vi.spyOn(task.diffViewProvider, "reset").mockResolvedValue(undefined) | ||
| vi.spyOn(getTaskTestAccess(task), "safeEnsureModelFetched").mockResolvedValue(stubModelInfo) | ||
| vi.spyOn(getTaskTestAccess(task), "presentAssistantMessageSafe").mockImplementation(() => {}) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Script executed:
Repository: Zoo-Code-Org/Zoo-Code
Length of output: 7383
Script executed:
Repository: Zoo-Code-Org/Zoo-Code
Length of output: 18124
Script executed:
Repository: Zoo-Code-Org/Zoo-Code
Length of output: 15533
Track context-window retries separately from history attempts.
After five automatic mid-stream retries, the approval path pushes
retryAttemptas6.attemptApiRequestthen skips truncation because6 < MAX_CONTEXT_WINDOW_RETRIESis false. With auto-approval enabled, the fallback recursively retries without a separate limit.Keep the positive history attempt, but use an independent counter for context-window recovery. Increment that counter only for context-window retries and reset it for the new approved attempt.
The automatic mid-stream path already incremented
retryAttemptbefore this PR. The PR adds the approved path that preserves this inflated value, so the shared-counter issue is pre-existing but remains actionable in the new capped-retry flow.GitHub Check: mutation-diff
[warning] 4701-4701: Mutation test advisory
src/core/task/Task.ts:4701: 2 mutation test gaps; example: Survived ArithmeticOperator mutant (replacement: (currentItem.retryAttempt ?? 0) - 1). See the job summary for the complete list and resolution guidance.