Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
16 changes: 16 additions & 0 deletions packages/types/src/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,21 @@ export const contextCondenseSchema = z.object({

export type ContextCondense = z.infer<typeof contextCondenseSchema>

/**
* RateLimitRetryMetadata
*/
export const rateLimitRetrySchema = z.object({
type: z.literal("rate_limit_retry"),
status: z.enum(["waiting", "retrying", "cancelled"]),
remainingSeconds: z.number().optional(),
attempt: z.number().optional(),
maxAttempts: z.number().optional(),
origin: z.enum(["pre_request", "retry_attempt"]).optional(),
detail: z.string().optional(),
})

export type RateLimitRetryMetadata = z.infer<typeof rateLimitRetrySchema>

/**
* ClineMessage
*/
Expand Down Expand Up @@ -223,6 +238,7 @@ export const clineMessageSchema = z.object({
previous_response_id: z.string().optional(),
})
.optional(),
rateLimitRetry: rateLimitRetrySchema.optional(),
})
.optional(),
})
Expand Down
258 changes: 234 additions & 24 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,16 @@ const DEFAULT_USAGE_COLLECTION_TIMEOUT_MS = 5000 // 5 seconds
const FORCED_CONTEXT_REDUCTION_PERCENT = 75 // Keep 75% of context (remove 25%) on context window errors
const MAX_CONTEXT_WINDOW_RETRIES = 3 // Maximum retries for context window errors

interface RateLimitRetryPayload {
type: "rate_limit_retry"
status: "waiting" | "retrying" | "cancelled"
remainingSeconds?: number
attempt?: number
maxAttempts?: number
origin: "pre_request" | "retry_attempt"
detail?: string
}

export interface TaskOptions extends CreateTaskOptions {
provider: ClineProvider
apiConfiguration: ProviderSettings
Expand Down Expand Up @@ -1100,8 +1110,12 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
if (partial !== undefined) {
const lastMessage = this.clineMessages.at(-1)

const isRateLimitUpdate = type === "api_req_retry_delayed" && options.metadata?.rateLimitRetry !== undefined
const isUpdatingPreviousPartial =
lastMessage && lastMessage.partial && lastMessage.type === "say" && lastMessage.say === type
lastMessage &&
lastMessage.type === "say" &&
lastMessage.say === type &&
(lastMessage.partial || isRateLimitUpdate)

if (partial) {
if (isUpdatingPreviousPartial) {
Expand All @@ -1110,6 +1124,13 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
lastMessage.images = images
lastMessage.partial = partial
lastMessage.progressStatus = progressStatus
if (options.metadata) {
const messageWithMetadata = lastMessage as ClineMessage & ClineMessageWithMetadata
if (!messageWithMetadata.metadata) {
messageWithMetadata.metadata = {}
}
Object.assign(messageWithMetadata.metadata, options.metadata)
}
this.updateClineMessage(lastMessage)
} else {
// This is a new partial message, so add it with partial state.
Expand Down Expand Up @@ -1197,6 +1218,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
images,
checkpoint,
contextCondense,
metadata: options.metadata,
})
}
}
Expand Down Expand Up @@ -2655,6 +2677,124 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {

let rateLimitDelay = 0

const sendRateLimitUpdate = async (payload: RateLimitRetryPayload, isPartial: boolean): Promise<void> => {
await this.say("api_req_retry_delayed", undefined, undefined, isPartial, undefined, undefined, {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Mark rate-limit status updates as non-interactive

api_req_retry_delayed is a UI-only status row and shouldn't mutate lastMessageTs. Currently sendRateLimitUpdate calls say() without isNonInteractive, which updates lastMessageTs for new messages in say(). This can interfere with Task.ask's blocking/waiting logic if a status update races with an ask.

Setting isNonInteractive true keeps the message ordering/UI unchanged while avoiding unintended ask lifecycle interactions.

Suggested change
await this.say("api_req_retry_delayed", undefined, undefined, isPartial, undefined, undefined, {
await this.say("api_req_retry_delayed", undefined, undefined, isPartial, undefined, undefined, {
isNonInteractive: true,
metadata: { rateLimitRetry: payload },
})

metadata: { rateLimitRetry: payload },
})
}

const runRateLimitCountdown = async ({
seconds,
origin,
attempt,
maxAttempts,
detail,
}: {
seconds: number
origin: RateLimitRetryPayload["origin"]
attempt?: number
maxAttempts?: number
detail?: string
}): Promise<boolean> => {
const normalizedSeconds = Math.max(0, Math.ceil(seconds))

if (normalizedSeconds <= 0) {
if (this.abort) {
await sendRateLimitUpdate(
{
type: "rate_limit_retry",
status: "cancelled",
remainingSeconds: 0,
attempt,
maxAttempts,
origin,
detail,
},
false,
)
return false
}

await sendRateLimitUpdate(
{
type: "rate_limit_retry",
status: "retrying",
remainingSeconds: 0,
attempt,
maxAttempts,
origin,
detail,
},
false,
)
return true
}

for (let i = normalizedSeconds; i > 0; i--) {
if (this.abort) {
await sendRateLimitUpdate(
{
type: "rate_limit_retry",
status: "cancelled",
remainingSeconds: i,
attempt,
maxAttempts,
origin,
detail,
},
false,
)
return false
}

await sendRateLimitUpdate(
{
type: "rate_limit_retry",
status: "waiting",
remainingSeconds: i,
attempt,
maxAttempts,
origin,
detail,
},
true,
)

await delay(1000)
}

if (this.abort) {
await sendRateLimitUpdate(
{
type: "rate_limit_retry",
status: "cancelled",
remainingSeconds: 0,
attempt,
maxAttempts,
origin,
detail,
},
false,
)
return false
}

await sendRateLimitUpdate(
{
type: "rate_limit_retry",
status: "retrying",
remainingSeconds: 0,
attempt,
maxAttempts,
origin,
detail,
},
false,
)

return true
}

// Use the shared timestamp so that subtasks respect the same rate-limit
// window as their parent tasks.
if (Task.lastGlobalApiRequestTime) {
Expand All @@ -2666,11 +2806,16 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {

// Only show rate limiting message if we're not retrying. If retrying, we'll include the delay there.
if (rateLimitDelay > 0 && retryAttempt === 0) {
// Show countdown timer
for (let i = rateLimitDelay; i > 0; i--) {
const delayMessage = `Rate limiting for ${i} seconds...`
await this.say("api_req_retry_delayed", delayMessage, undefined, true)
await delay(1000)
const countdownCompleted = await runRateLimitCountdown({
seconds: rateLimitDelay,
origin: "pre_request",
attempt: 1,
})

if (!countdownCompleted) {
throw new Error(
`[RooCode#attemptApiRequest] task ${this.taskId}.${this.instanceId} aborted during pre-request rate limit wait`,
)
}
}

Expand Down Expand Up @@ -2822,7 +2967,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {

// note that this api_req_failed ask is unique in that we only present this option if the api hasn't streamed any content yet (ie it fails on the first chunk due), as it would allow them to hit a retry button. However if the api failed mid-stream, it could be in any arbitrary state where some tools may have executed, so that error is handled differently and requires cancelling the task entirely.
if (autoApprovalEnabled && alwaysApproveResubmit) {
let errorMsg
let errorMsg: string

if (error.error?.metadata?.raw) {
errorMsg = JSON.stringify(error.error.metadata.raw, null, 2)
Expand All @@ -2843,7 +2988,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
`[Task#attemptApiRequest] task ${this.taskId}.${this.instanceId} aborted during retry`,
)
}

// Delegate generator output from the recursive call with
// incremented retry count.
yield* this.attemptApiRequest(retryAttempt + 1)
Expand Down Expand Up @@ -2913,43 +3058,108 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
const finalDelay = Math.max(exponentialDelay, rateLimitDelay)
if (finalDelay <= 0) return

// Build header text; fall back to error message if none provided
let headerText = header
if (!headerText) {
// Build detail text; fall back to error message if none provided
let errorMsg = header
if (!errorMsg) {
if (error?.error?.metadata?.raw) {
headerText = JSON.stringify(error.error.metadata.raw, null, 2)
errorMsg = JSON.stringify(error.error.metadata.raw, null, 2)
} else if (error?.message) {
headerText = error.message
errorMsg = error.message
} else {
headerText = "Unknown error"
errorMsg = "Unknown error"
}
}

// Sanitize detail for UI display
const sanitizedDetail = (() => {
if (!errorMsg) {
return undefined
}
const firstLine = errorMsg
.split("\n")
.map((line) => line.trim())
.find((line) => line.length > 0)
if (!firstLine) {
return undefined
}
return firstLine.length > 160 ? `${firstLine.slice(0, 157)}…` : firstLine
})()

// Helper to send rate limit updates with structured metadata
const sendRateLimitUpdate = async (
payload: RateLimitRetryPayload,
isPartial: boolean,
): Promise<void> => {
await this.say("api_req_retry_delayed", undefined, undefined, isPartial, undefined, undefined, {
metadata: { rateLimitRetry: payload },
})
}
headerText = headerText ? `${headerText}\n\n` : ""

// Show countdown timer with exponential backoff
// Show countdown timer with exponential backoff using structured metadata
for (let i = finalDelay; i > 0; i--) {
// Check abort flag during countdown to allow early exit
if (this.abort) {
await sendRateLimitUpdate(
{
type: "rate_limit_retry",
status: "cancelled",
remainingSeconds: i,
attempt: retryAttempt + 1,
origin: "retry_attempt",
detail: sanitizedDetail,
},
false,
)
throw new Error(`[Task#${this.taskId}] Aborted during retry countdown`)
}

await this.say(
"api_req_retry_delayed",
`${headerText}Retry attempt ${retryAttempt + 1}\nRetrying in ${i} seconds...`,
undefined,
await sendRateLimitUpdate(
{
type: "rate_limit_retry",
status: "waiting",
remainingSeconds: i,
attempt: retryAttempt + 1,
origin: "retry_attempt",
detail: sanitizedDetail,
},
true,
)
await delay(1000)
}

await this.say(
"api_req_retry_delayed",
`${headerText}Retry attempt ${retryAttempt + 1}\nRetrying now...`,
undefined,
// Final check before retrying
if (this.abort) {
await sendRateLimitUpdate(
{
type: "rate_limit_retry",
status: "cancelled",
remainingSeconds: 0,
attempt: retryAttempt + 1,
origin: "retry_attempt",
detail: sanitizedDetail,
},
false,
)
throw new Error(`[Task#${this.taskId}] Aborted during retry countdown`)
}

await sendRateLimitUpdate(
{
type: "rate_limit_retry",
status: "retrying",
remainingSeconds: 0,
attempt: retryAttempt + 1,
origin: "retry_attempt",
detail: sanitizedDetail,
},
false,
)
} catch (err) {
console.error("Exponential backoff failed:", err)
// Re-throw if it's an abort error so it propagates correctly
if (err instanceof Error && err.message.includes("Aborted during retry countdown")) {
throw err
}
}
}

Expand Down
Loading