From 912b48adad59613f46aa6c05e8e8d9416876e9b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=82CHES?= Date: Mon, 16 Mar 2026 21:03:46 -0600 Subject: [PATCH] fix: auto-resume auto-mode after rate limit cooldown (#756) (#776) When auto-mode pauses due to a rate limit, schedule automatic resumption after the rate limit window elapses. Shows a countdown notification so the user knows what's happening. Non-rate-limit errors still pause indefinitely for manual intervention. Co-authored-by: Claude Opus 4.6 (1M context) --- src/resources/extensions/gsd/index.ts | 18 ++++- .../extensions/gsd/provider-error-pause.ts | 31 ++++++- .../tests/agent-end-provider-error.test.ts | 81 +++++++++++++++++++ 3 files changed, 127 insertions(+), 3 deletions(-) diff --git a/src/resources/extensions/gsd/index.ts b/src/resources/extensions/gsd/index.ts index 8a3cc6d6e..615dd6e38 100644 --- a/src/resources/extensions/gsd/index.ts +++ b/src/resources/extensions/gsd/index.ts @@ -716,7 +716,23 @@ export default function (pi: ExtensionAPI) { } } - await pauseAutoForProviderError(ctx.ui, errorDetail, () => pauseAuto(ctx, pi)); + // Detect rate-limit errors and extract retry delay for auto-resume + const errorMsg = ("errorMessage" in lastMsg && lastMsg.errorMessage) ? String(lastMsg.errorMessage) : ""; + const isRateLimit = /rate.?limit|too many requests|429/i.test(errorMsg); + const retryAfterMs = ("retryAfterMs" in lastMsg && typeof lastMsg.retryAfterMs === "number") + ? lastMsg.retryAfterMs + : (() => { const m = errorMsg.match(/reset in (\d+)s/i); return m ? Number(m[1]) * 1000 : undefined; })(); + + await pauseAutoForProviderError(ctx.ui, errorDetail, () => pauseAuto(ctx, pi), { + isRateLimit, + retryAfterMs, + resume: () => { + pi.sendMessage( + { customType: "gsd-auto-timeout-recovery", content: "Continue execution \u2014 rate limit window elapsed.", display: false }, + { triggerTurn: true }, + ); + }, + }); return; } diff --git a/src/resources/extensions/gsd/provider-error-pause.ts b/src/resources/extensions/gsd/provider-error-pause.ts index 954c1774b..5800d7e28 100644 --- a/src/resources/extensions/gsd/provider-error-pause.ts +++ b/src/resources/extensions/gsd/provider-error-pause.ts @@ -2,11 +2,38 @@ export type ProviderErrorPauseUI = { notify(message: string, level?: "info" | "warning" | "error" | "success"): void; }; +/** + * Pause auto-mode due to a provider error. + * + * For rate-limit errors with a known reset delay, schedules an automatic + * resume after the delay and shows a countdown notification. For all other + * errors, pauses indefinitely (user must manually resume). + */ export async function pauseAutoForProviderError( ui: ProviderErrorPauseUI, errorDetail: string, pause: () => Promise, + options?: { + isRateLimit?: boolean; + retryAfterMs?: number; + resume?: () => void; + }, ): Promise { - ui.notify(`Auto-mode paused due to provider error${errorDetail}`, "warning"); - await pause(); + if (options?.isRateLimit && options.retryAfterMs && options.retryAfterMs > 0 && options.resume) { + const delaySec = Math.ceil(options.retryAfterMs / 1000); + ui.notify( + `Rate limited${errorDetail}. Auto-resuming in ${delaySec}s...`, + "warning", + ); + await pause(); + + // Schedule auto-resume after the rate limit window + setTimeout(() => { + ui.notify("Rate limit window elapsed. Resuming auto-mode.", "info"); + options.resume!(); + }, options.retryAfterMs); + } else { + ui.notify(`Auto-mode paused due to provider error${errorDetail}`, "warning"); + await pause(); + } } diff --git a/src/resources/extensions/gsd/tests/agent-end-provider-error.test.ts b/src/resources/extensions/gsd/tests/agent-end-provider-error.test.ts index 5be2aa498..af00e6a27 100644 --- a/src/resources/extensions/gsd/tests/agent-end-provider-error.test.ts +++ b/src/resources/extensions/gsd/tests/agent-end-provider-error.test.ts @@ -27,3 +27,84 @@ test("pauseAutoForProviderError warns and pauses without requiring ctx.log", asy }, ]); }); + +test("pauseAutoForProviderError schedules auto-resume for rate limit errors", async () => { + const notifications: Array<{ message: string; level: string }> = []; + let pauseCalls = 0; + let resumeCalled = false; + + // Use fake timer + const originalSetTimeout = globalThis.setTimeout; + const timers: Array<{ fn: () => void; delay: number }> = []; + globalThis.setTimeout = ((fn: () => void, delay: number) => { + timers.push({ fn, delay }); + return 0 as unknown as ReturnType; + }) as typeof setTimeout; + + try { + await pauseAutoForProviderError( + { + notify(message, level?) { + notifications.push({ message, level: level ?? "info" }); + }, + }, + ": rate limit exceeded", + async () => { + pauseCalls += 1; + }, + { + isRateLimit: true, + retryAfterMs: 90000, + resume: () => { + resumeCalled = true; + }, + }, + ); + + assert.equal(pauseCalls, 1, "should pause auto-mode"); + assert.equal(timers.length, 1, "should schedule one timer"); + assert.equal(timers[0].delay, 90000, "timer should match retryAfterMs"); + assert.deepEqual(notifications[0], { + message: "Rate limited: rate limit exceeded. Auto-resuming in 90s...", + level: "warning", + }); + + // Fire the timer + timers[0].fn(); + assert.equal(resumeCalled, true, "should call resume after timer fires"); + assert.deepEqual(notifications[1], { + message: "Rate limit window elapsed. Resuming auto-mode.", + level: "info", + }); + } finally { + globalThis.setTimeout = originalSetTimeout; + } +}); + +test("pauseAutoForProviderError falls back to indefinite pause when not rate limit", async () => { + const notifications: Array<{ message: string; level: string }> = []; + let pauseCalls = 0; + + await pauseAutoForProviderError( + { + notify(message, level?) { + notifications.push({ message, level: level ?? "info" }); + }, + }, + ": connection refused", + async () => { + pauseCalls += 1; + }, + { + isRateLimit: false, + }, + ); + + assert.equal(pauseCalls, 1); + assert.deepEqual(notifications, [ + { + message: "Auto-mode paused due to provider error: connection refused", + level: "warning", + }, + ]); +});