fix(daemon): stop reading our own empty-output fallback as a hard quota - #6261
fix(daemon): stop reading our own empty-output fallback as a hard quota#6261maxmilian wants to merge 3 commits into
Conversation
`isHardQuotaText` carried a bare `quota` alternative, so it matched the daemon's own generic empty-output fallback (server.ts): "...then try re-authenticating the agent, checking quota, or switching models." The quota branch runs before `isEmptyOutputText`, so every output-less run — for any reason — was classified `hard_quota`, and run-retry-policy suppresses retries outright on that detail. That is why third-party API runs failed permanently while the provider itself still had quota. `quota` alone is no longer a signal; it needs a corroborating word. This mirrors the detector that already got it right — integrations/vela-errors.ts requires wallet/balance/credit/billing/funds alongside `quota`. Phrases that are unambiguous on their own (session limit, insufficient quota, exceeded your current quota, the Chinese vela pre-charge texts) keep matching directly. Regression test feeds the exact server.ts fallback string and asserts it lands on `empty_output`, plus a guard that corroborated quota messages still classify as `hard_quota` and that the Chinese pre-charge text keeps its more specific `amr_insufficient_balance` detail. Refs nexu-io#6143
|
Thanks @maxmilian — scoping this to item (1) from #6143 and testing against the exact daemon fallback string is the right shape. I've routed it into review now, and I'll handle the PR-body / validation follow-up on this thread while pool review picks up the code pass. |
|
Thanks for scoping this tightly to item (1) and for grounding it in the exact #6143 fallback path. Before pool review goes further, could you add the missing PR-template pieces — a short Why, What users will see, Surface area, and Validation section? The substance is mostly already in your write-up; this is mainly to make the reviewer-facing summary explicit. |
|
🧪 Queued for QA validation — this PR has changes that need a manual QA pass before it's merged. Nothing needed from you; we'll update here once it's validated. Thanks for the contribution! 🙏 |
PerishCode
left a comment
There was a problem hiding this comment.
The empty-output regression is correctly reproduced, but the replacement hard-quota predicate drops common unambiguous exhaustion forms. This needs a small matcher/test adjustment before the fix is safe to merge.
🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.| return /\bquota\b/i.test(text) && | ||
| /\b(wallet|balance|credits?|billing|funds?|payment|plan)\b/i.test(text); | ||
| } | ||
|
|
There was a problem hiding this comment.
Preserve explicit exhaustion phrases such as quota exhausted and quota exceeded as hard quota signals. This new conjunction only recognizes quota when one of wallet/balance/credit/billing/funds/payment/plan also appears, so classify('AGENT_EXECUTION_FAILED', 'quota exhausted') now falls through to the generic retryable execution-failure path; with RATE_LIMITED, the same text becomes retryable rate_limit_429. That changes a genuine terminal quota failure into a retry candidate, contrary to the existing hard_quota suppression contract. The repository already contains the exact upstream phrase quota exhausted in apps/daemon/tests/byok-tools.test.ts, and quota exceeded is also a common provider form. Add tightly scoped alternatives such as quota (?:exceeded|exhausted|depleted|reached) (without restoring bare quota) and include these terse forms in the fixture matrix alongside the empty-output fallback.
|
Hey @maxmilian — PerishCode has the current blocker covered on this head: the quota matcher fix now misses terse terminal exhaustion forms like 💡 To drive this PR to merge hands-free, paste this to your AI coding agent (Claude Code / Codex / opencode / Cursor …): |
Review caught a recall regression in the previous commit: requiring a
corroborating word for `quota` also dropped phrases that are unambiguous on
their own. `quota exhausted` is a real upstream payload in this repo
(tests/byok-tools.test.ts: `status_msg: 'quota exhausted'`), and under
`RATE_LIMITED` it would have become a retryable `rate_limit_429` — turning a
terminal quota failure into a retry candidate, the exact inverse of the bug
this PR fixes.
`quota` adjacent to an exhaustion word (exceeded / exhausted / depleted /
reached / used up, in either order) now matches directly, without needing
wallet/balance/billing. The daemon's own fallback ("...checking quota, or
switching models.") still doesn't match, which is the invariant that started
this PR.
Tests cover both directions and both error codes, since RATE_LIMITED is where
the suppression contract actually breaks.
|
Good catch — that's a real recall regression, and I verified the evidence you cited before fixing: What was wrong: requiring a corroborating word for Fix: /\bquota\s+(?:exceeded|exhausted|depleted|reached|used\s+up)\b/i
/\b(?:exceeded|exhausted|out\s+of|ran\s+out\s+of|no\s+remaining)\s+(?:\w+\s+){0,3}quota\b/iThe corroboration rule now only governs a bare Tests cover
|
PerishCode
left a comment
There was a problem hiding this comment.
The explicit exhaustion-phrase regression is fixed, but the fallback can still be reclassified by an unrelated event fragment because corroboration is applied across the full aggregated failure text. This remains a retry-suppression risk on the empty-output path.
🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.| } | ||
|
|
There was a problem hiding this comment.
Keep the corroborating term in the same message as quota. collectFailureText joins the status error and up to 24 event fragments with newlines, but these two independent regexes search that whole aggregate. Therefore the exact empty-output fallback supplies quota, while an unrelated event such as Failed to load plan supplies plan; together they still return hard_quota, and run-retry-policy suppresses the retry this PR intends to restore. Match corroborators within the same line or a tightly bounded phrase around quota (or classify each collected fragment separately), and add a regression fixture combining EMPTY_OUTPUT_FALLBACK with an unrelated event containing plan or payment that must remain empty_output.
|
Hey @maxmilian — thanks for turning the first blocker around quickly. PerishCode’s current blocker on this head is narrower now: keep the corroborating term in the same collected fragment as |
collectFailureText joins up to 24 unrelated messages with '\n', so applying the corroboration rule to the joined string let any fragment mentioning a plan or a payment vouch for the bare `quota` inside our own empty-output fallback — putting the run back on the retry-suppressing hard_quota path. Evaluate the predicate per collected fragment instead. This also stops the `\s+` in the exhaustion-adjacency patterns from bridging two messages across the join.
|
Fixed in
While testing this I hit a second instance of the same root cause that isn't in your note: New tests in
Red-verified before the change: with the previous
|
PerishCode
left a comment
There was a problem hiding this comment.
@maxmilian The current head correctly scopes hard-quota detection to individual collected fragments, preserves explicit exhaustion phrases under both generic and rate-limited codes, and adds regression coverage for unrelated-fragment and cross-newline false positives. I also verified the live PR head and successful required checks. Nicely handled—the follow-up closes both prior retry-suppression blockers with focused coverage.
🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.|
Hey @maxmilian — PerishCode’s current head is clear, so I’ve now queued the manual QA pass for this revision. We’ll update here once that validation comes back. |
Refs #6143 — item (1) only, as scoped in the thread.
The misclassification is self-inflicted
apps/daemon/src/server.tsemits a generic fallback when a run produces no output:isHardQuotaTextcarried a barequotaalternative, so it matched that sentence. And the quota branch is evaluated beforeisEmptyOutputText, soempty_outputwas unreachable for this text.Downstream,
run-retry-policy.tsdoesif (failure?.failure_detail === 'hard_quota') return suppress('hard_quota')— which is why the reporter saw it stick permanently rather than retry, on providers that had quota left.inputTokens/outputTokensbeing 0 with no upstream status line fits exactly.The fix follows a pattern already in this repo
apps/daemon/src/integrations/vela-errors.tsalready gets this right — it requires a corroborating word:isHardQuotaTextnow does the same:quotaon its own is not a signal, it needs one of wallet/balance/credits/billing/funds/payment/plan. Phrases that are unambiguous on their own keep matching directly —session limit,usage limit,insufficient quota,exceeded your current quota,out of credits,DAILY_LIMIT_EXCEEDED, and the Chinese vela pre-charge texts.Tests
Red-then-green in
apps/daemon/tests/run-failure-classification.test.ts, feeding the exactserver.tsfallback string:empty_output/empty_output(wasrate_limit/hard_quota)hard_quota,retryable: false— so this doesn't trade one silent misclassification for another用户额度不足keeps its more specificamr_insufficient_balancedetailrun-failure-classification.test.ts: 102 passed.pnpm typecheck: clean.Full
@open-design/daemonsuite: 6104 passed, 10 failed — all 10 pre-exist on unmodifiedupstream/main(tests/amr-session-resume.test.ts,tests/langfuse-trace.test.ts; verified with a stash A/B on the same machine: 10 failed / 89 passed either way).Scope
Deliberately item (1) only. Items (2)/(3) from the thread (the BYOK path itself and the diagnostics wording) are untouched, so the issue should stay open. Happy to take the retest offer on
openai.mozhevip.toponce this is buildable.