-
Notifications
You must be signed in to change notification settings - Fork 9.7k
fix(daemon): stop reading our own empty-output fallback as a hard quota #6261
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
b64a97e
55ef79c
f158190
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 |
|---|---|---|
|
|
@@ -128,7 +128,7 @@ function inferFailureStageFromEvents( | |
| return fallback; | ||
| } | ||
|
|
||
| function collectFailureText(input: RunFailureClassificationInput): string { | ||
| function collectFailureParts(input: RunFailureClassificationInput): string[] { | ||
| const parts: string[] = []; | ||
| const statusError = readString(input.status.error); | ||
| if (statusError) parts.push(statusError); | ||
|
|
@@ -143,12 +143,56 @@ function collectFailureText(input: RunFailureClassificationInput): string { | |
| parts.push(...eventStderrText(rec.data)); | ||
| } | ||
| } | ||
| return parts.join('\n'); | ||
| return parts; | ||
| } | ||
|
|
||
| function isHardQuotaText(text: string): boolean { | ||
| return /\b(session limit|usage limit|limit reached|quota|billing (?:hard )?limit|insufficient[ _-]?(?:quota|credit|credits|funds)|exceeded your current quota|out of credits|no payment method|requires more credits|can only afford)\b|DAILY_LIMIT_EXCEEDED|用户额度不足|额度不足|预扣费额度失败/i | ||
| .test(text); | ||
| function collectFailureText(input: RunFailureClassificationInput): string { | ||
| return collectFailureParts(input).join('\n'); | ||
| } | ||
|
|
||
| // A bare `quota` alternative used to live in the alternation below, which made | ||
| // this matcher fire on our *own* generic empty-output fallback (server.ts: | ||
| // "...then try re-authenticating the agent, checking quota, or switching | ||
| // models."). The quota branch is evaluated before `isEmptyOutputText`, so every | ||
| // output-less run — for any reason — was reported as an exhausted quota, and | ||
| // run-retry-policy suppresses retries outright on `hard_quota`. That is the | ||
| // misclassification behind #6143: third-party API runs failed permanently while | ||
| // the provider still had quota. | ||
| // | ||
| // So `quota` on its own is not a signal; it needs a corroborating word. This | ||
| // mirrors the detector that already got it right — | ||
| // integrations/vela-errors.ts requires one of wallet/balance/credit/billing/funds | ||
| // alongside `quota`. Phrases that are unambiguous on their own (session limit, | ||
| // insufficient quota, exceeded your current quota…) keep matching directly. | ||
| // | ||
| // Evaluated per collected fragment, never over the joined text. `collectFailureText` | ||
| // concatenates up to 24 unrelated messages with '\n', so whole-text corroboration | ||
| // would let *any* fragment mentioning a plan or payment vouch for a bare `quota` | ||
| // sitting in a different one — which is precisely how the empty-output fallback | ||
| // would find its way back to `hard_quota`. Fragment scope also keeps the `\s+` | ||
| // in the adjacency patterns below from bridging two messages across the join. | ||
| function isHardQuotaFragment(text: string): boolean { | ||
| if (/\b(session limit|usage limit|limit reached|billing (?:hard )?limit|insufficient[ _-]?(?:quota|credit|credits|funds)|exceeded your current quota|out of credits|no payment method|requires more credits|can only afford)\b|DAILY_LIMIT_EXCEEDED|用户额度不足|额度不足|预扣费额度失败/i | ||
| .test(text)) { | ||
| return true; | ||
| } | ||
| // `quota` next to an exhaustion word is unambiguous on its own — no wallet or | ||
| // balance needed. `quota exhausted` is a real upstream payload in this repo | ||
| // (tests/byok-tools.test.ts: `status_msg: 'quota exhausted'`); requiring | ||
| // corroboration for it would turn a terminal failure into a retry candidate | ||
| // (and under RATE_LIMITED, a retryable `rate_limit_429`), which is the exact | ||
| // inverse of the misclassification this function is being fixed for. | ||
| if (/\bquota\s+(?:exceeded|exhausted|depleted|reached|used\s+up)\b/i.test(text) | ||
| || /\b(?:exceeded|exhausted|out\s+of|ran\s+out\s+of|no\s+remaining)\s+(?:\w+\s+){0,3}quota\b/i | ||
| .test(text)) { | ||
| return true; | ||
| } | ||
| return /\bquota\b/i.test(text) && | ||
| /\b(wallet|balance|credits?|billing|funds?|payment|plan)\b/i.test(text); | ||
| } | ||
|
|
||
|
Comment on lines
192
to
193
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. Keep the corroborating term in the same message as |
||
| function isHardQuotaText(parts: string[]): boolean { | ||
| return parts.some(isHardQuotaFragment); | ||
| } | ||
|
|
||
| // A transient, retryable rate limit (distinct from a hard quota). vela/upstream | ||
|
|
@@ -636,7 +680,8 @@ export function classifyRunFailure( | |
| } | ||
|
|
||
| const errorCode = normalizeCode(input.errorCode ?? input.status.errorCode); | ||
| const text = collectFailureText(input); | ||
| const parts = collectFailureParts(input); | ||
| const text = parts.join('\n'); | ||
| const retryableHint = latestRetryable(input.events); | ||
| const amrFailure = classifyAmrAccountFailure(text); | ||
| const byokOpenCodeProviderNotFound = isByokOpenCodeProviderNotFoundText( | ||
|
|
@@ -810,8 +855,8 @@ export function classifyRunFailure( | |
| ); | ||
| } | ||
|
|
||
| if (errorCode === 'RATE_LIMITED' || serviceFailure === 'RATE_LIMITED' || isHardQuotaText(text) || isRateLimitText(text)) { | ||
| const hardQuota = isHardQuotaText(text); | ||
| const hardQuota = isHardQuotaText(parts); | ||
| if (errorCode === 'RATE_LIMITED' || serviceFailure === 'RATE_LIMITED' || hardQuota || isRateLimitText(text)) { | ||
| const workspaceCredits = isWorkspaceCreditsText(text); | ||
| const retryable = hardQuota ? false : (retryableHint ?? true); | ||
| return classification( | ||
|
|
||
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.
Preserve explicit exhaustion phrases such as
🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.quota exhaustedandquota exceededas hard quota signals. This new conjunction only recognizesquotawhen one of wallet/balance/credit/billing/funds/payment/plan also appears, soclassify('AGENT_EXECUTION_FAILED', 'quota exhausted')now falls through to the generic retryable execution-failure path; withRATE_LIMITED, the same text becomes retryablerate_limit_429. That changes a genuine terminal quota failure into a retry candidate, contrary to the existinghard_quotasuppression contract. The repository already contains the exact upstream phrasequota exhaustedinapps/daemon/tests/byok-tools.test.ts, andquota exceededis also a common provider form. Add tightly scoped alternatives such asquota (?:exceeded|exhausted|depleted|reached)(without restoring barequota) and include these terse forms in the fixture matrix alongside the empty-output fallback.