Skip to content

Commit f1cc58a

Browse files
Pyinerclaude
andcommitted
feat(codex): auto-resend on usage-quota exhaustion with countdown banner
Detect Codex 5-hour/weekly quota exhaustion from the app-server's structured signals (error.codexErrorInfo == usageLimitExceeded plus the account/rateLimits/updated snapshot's reset time), surface a countdown banner in the Mac and iOS apps, and automatically resend the original user message the moment the quota window recovers. - bridge: consume the rate-limit snapshot + usage-limit error in codex_provider; stage ProviderRateLimit via take_rate_limit; promote the terminal run_complete control to status=rate_limited with a rate_limit payload. Classify only on an explicit quota signal, never on bare window saturation. - models: derive RenderSnapshot.rateLimit from the committed ledger (write-then-derive), cleared on run_start/run_interrupted. - gateway: quota_resend reactor schedules a one-shot, per-thread, restart-safe cron resend at reset time; serial in-order scheduling, replays history on broadcast lag, and strips followup metadata so resends never nest. - desktop + mobile: dumb-render a rate-limit banner with a local countdown. Cross-reviewed by Codex and Claude; addressed prefix-compounding, false-positive classification, reactor ordering/lag, and stale-stash issues. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e45a664 commit f1cc58a

20 files changed

Lines changed: 1471 additions & 8 deletions

File tree

desktop/garyx-desktop/src/renderer/src/app-shell/AppShell.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2865,6 +2865,7 @@ export function AppShell() {
28652865
// window); the tool shimmer keys off `activeToolGroupId`. assistant_streaming
28662866
// / tool_active are carried by the rows themselves, not a separate bubble.
28672867
const activeToolGroupId = activeRenderState?.activeToolGroupId ?? null;
2868+
const activeRateLimit = activeRenderState?.rateLimit ?? null;
28682869
const showTailThinking = Boolean(
28692870
activeRenderState?.tailActivity === "thinking" || showPendingAckLoading,
28702871
);
@@ -9822,6 +9823,7 @@ export function AppShell() {
98229823
showDreams={showDreamsFeature}
98239824
showHistoryLoadingPlaceholder={showHistoryLoadingPlaceholder}
98249825
showTailThinking={showTailThinking}
9826+
rateLimit={activeRateLimit}
98259827
threadLayoutRef={threadLayoutRef}
98269828
threadLayoutStyle={
98279829
!embedded && threadLogsOpen
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { useEffect, useState, type CSSProperties } from "react";
2+
3+
import type { RenderRateLimit } from "@shared/contracts";
4+
5+
import { useI18n } from "../../i18n";
6+
7+
/**
8+
* Quota / rate-limit banner rendered at the tail of a thread when its most
9+
* recent run terminated because the provider's rolling usage quota was
10+
* exhausted. The countdown ticks locally off the server-provided `resetAt`, so
11+
* no streaming updates are required. When `willAutoResend` is set the gateway
12+
* has scheduled an automatic resend of the original message at reset time.
13+
*/
14+
export function RateLimitBanner({
15+
rateLimit,
16+
}: {
17+
rateLimit?: RenderRateLimit | null;
18+
}) {
19+
const { t } = useI18n();
20+
const [now, setNow] = useState(() => Date.now());
21+
22+
const resetMs = rateLimit?.resetAt ? Date.parse(rateLimit.resetAt) : Number.NaN;
23+
const hasReset = Number.isFinite(resetMs);
24+
25+
useEffect(() => {
26+
if (!rateLimit || !hasReset) {
27+
return;
28+
}
29+
const id = window.setInterval(() => setNow(Date.now()), 1000);
30+
return () => window.clearInterval(id);
31+
}, [rateLimit, hasReset]);
32+
33+
if (!rateLimit) {
34+
return null;
35+
}
36+
37+
const provider = providerLabel(rateLimit.provider);
38+
const windowText = windowLabel(rateLimit.window, t);
39+
const remaining = hasReset ? resetMs - now : Number.NaN;
40+
const recovered = hasReset && remaining <= 0;
41+
42+
const title = windowText
43+
? t("{provider} {window} reached")
44+
.replace("{provider}", provider)
45+
.replace("{window}", windowText)
46+
: t("{provider} usage limit reached").replace("{provider}", provider);
47+
48+
let detail: string;
49+
if (rateLimit.willAutoResend) {
50+
if (!hasReset) {
51+
detail = t("Will auto-resend when the quota recovers.");
52+
} else if (recovered) {
53+
detail = t("Quota recovered — resending…");
54+
} else {
55+
detail = t("Auto-resend in {time}").replace(
56+
"{time}",
57+
formatRemaining(remaining),
58+
);
59+
}
60+
} else if (hasReset && !recovered) {
61+
detail = t("Resets in {time}").replace("{time}", formatRemaining(remaining));
62+
} else {
63+
detail = t("Try again shortly.");
64+
}
65+
66+
return (
67+
<article
68+
aria-live="polite"
69+
className="rate-limit-banner"
70+
role="status"
71+
style={bannerStyle}
72+
>
73+
<span style={dotStyle} aria-hidden="true" />
74+
<span style={textColumnStyle}>
75+
<span style={titleStyle}>{title}</span>
76+
<span style={detailStyle}>{detail}</span>
77+
</span>
78+
</article>
79+
);
80+
}
81+
82+
function providerLabel(provider?: string | null): string {
83+
const slug = (provider ?? "").trim().toLowerCase();
84+
if (slug.startsWith("codex")) {
85+
return "Codex";
86+
}
87+
if (slug.startsWith("trae")) {
88+
return "TRAE";
89+
}
90+
return provider && provider.trim() ? provider.trim() : "Provider";
91+
}
92+
93+
function windowLabel(
94+
window: string | null | undefined,
95+
t: (key: string) => string,
96+
): string | null {
97+
switch (window) {
98+
case "primary":
99+
return t("5-hour limit");
100+
case "secondary":
101+
return t("weekly limit");
102+
default:
103+
return null;
104+
}
105+
}
106+
107+
function formatRemaining(ms: number): string {
108+
const total = Math.max(0, Math.floor(ms / 1000));
109+
const hours = Math.floor(total / 3600);
110+
const minutes = Math.floor((total % 3600) / 60);
111+
const seconds = total % 60;
112+
const pad = (value: number): string => String(value).padStart(2, "0");
113+
return hours > 0
114+
? `${hours}:${pad(minutes)}:${pad(seconds)}`
115+
: `${pad(minutes)}:${pad(seconds)}`;
116+
}
117+
118+
const bannerStyle: CSSProperties = {
119+
display: "flex",
120+
alignItems: "flex-start",
121+
gap: 10,
122+
margin: "8px 0",
123+
padding: "10px 14px",
124+
borderRadius: 10,
125+
border: "1px solid #f0e2c4",
126+
background: "#fdf6e9",
127+
color: "#7a5d1f",
128+
};
129+
130+
const dotStyle: CSSProperties = {
131+
flex: "0 0 auto",
132+
width: 8,
133+
height: 8,
134+
marginTop: 5,
135+
borderRadius: "50%",
136+
background: "#d99a2b",
137+
};
138+
139+
const textColumnStyle: CSSProperties = {
140+
display: "flex",
141+
flexDirection: "column",
142+
gap: 2,
143+
minWidth: 0,
144+
};
145+
146+
const titleStyle: CSSProperties = {
147+
fontSize: 13,
148+
fontWeight: 600,
149+
lineHeight: 1.4,
150+
};
151+
152+
const detailStyle: CSSProperties = {
153+
fontSize: 12.5,
154+
lineHeight: 1.4,
155+
fontVariantNumeric: "tabular-nums",
156+
opacity: 0.9,
157+
};

desktop/garyx-desktop/src/renderer/src/app-shell/components/ThreadPage.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import type {
2222
DesktopWorkspace,
2323
DesktopWorkspaceMode,
2424
PendingThreadInput,
25+
RenderRateLimit,
2526
RenderState,
2627
SlashCommand,
2728
TranscriptMessage,
@@ -34,6 +35,7 @@ import {
3435
type ComposerWorkflowOption,
3536
} from "../../ComposerForm";
3637
import { ComposerQueue } from "../../ComposerQueue";
38+
import { RateLimitBanner } from "./RateLimitBanner";
3739
import { NewThreadEmptyState } from "../../NewThreadEmptyState";
3840
import {
3941
RichMessageContent,
@@ -308,6 +310,7 @@ type ThreadPageProps = {
308310
showDreams: boolean;
309311
showHistoryLoadingPlaceholder: boolean;
310312
showTailThinking: boolean;
313+
rateLimit?: RenderRateLimit | null;
311314
threadLayoutRef: RefObject<HTMLDivElement | null>;
312315
threadLayoutStyle?: CSSProperties;
313316
threadLogsActiveTab: ThreadLogTab;
@@ -491,6 +494,7 @@ export function ThreadPage({
491494
showDreams,
492495
showHistoryLoadingPlaceholder,
493496
showTailThinking,
497+
rateLimit,
494498
threadLayoutRef,
495499
threadLayoutStyle,
496500
threadLogsActiveTab,
@@ -955,6 +959,8 @@ export function ThreadPage({
955959
</div>
956960
</article>
957961
) : null}
962+
963+
<RateLimitBanner rateLimit={rateLimit} />
958964
</div>
959965
) : null}
960966

desktop/garyx-desktop/src/shared/contracts.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1414,6 +1414,14 @@ export interface RenderFilteredPlaceholder {
14141414
reason: RenderPlaceholderFilterReason;
14151415
}
14161416

1417+
export interface RenderRateLimit {
1418+
provider?: string | null;
1419+
resetAt?: string | null;
1420+
window?: string | null;
1421+
message?: string | null;
1422+
willAutoResend: boolean;
1423+
}
1424+
14171425
export interface RenderState {
14181426
based_on_seq: number;
14191427
rows: RenderRow[];
@@ -1422,6 +1430,7 @@ export interface RenderState {
14221430
progress_locus: RenderProgressLocus;
14231431
visibleMessageIds: string[];
14241432
filtered_placeholders: RenderFilteredPlaceholder[];
1433+
rateLimit?: RenderRateLimit | null;
14251434
}
14261435

14271436
export interface CommittedMessageEvent {

0 commit comments

Comments
 (0)