fix(cli): a quota stall was classified as a runtime blip and probed every 5s - #995
Conversation
…very 5s `QUOTA_RE` matched "usage limit" but not "session limit", so the Claude CLI's exhaustion wording — "You've hit your session limit · resets 4am" — missed every alternative and fell through to RUNTIME. That is the weakest class with the shortest backoff, so the fleet answered a provider that was not going to respond for hours by probing it at 5.6s, 11.2s, then 1.1m. QUOTA trips the circuit on the first failure at the full 15-minute ceiling, which is the correct response to a quota that resets at a wall-clock hour. The instructive part is that `usage limit` was already in the list. It is Claude's OTHER exhaustion wording, so this stalled on a string one word away from a pattern we had. That makes it the second instance of the same shape: `out of credits` was added on 2026-08-03 after codex's wording classified as RUNTIME during a live outage. The pattern list is a per-provider allowlist that only grows after it has already failed, and the comment now says so with both dates. Not loosened to a bare `limit`, deliberately: QUOTA is tested before RATE_LIMIT, so a looser pattern would swallow every rate-limit error into a 15-minute cooldown. The added test asserts that directly rather than leaving it to review — "429 rate limit exceeded" and "Too many requests" must still classify RATE_LIMIT after the widening. Verified red first against the live string: both defect tests failed as RUNTIME, the rate-limit guard passed before and after. This does NOT rescue the events lost in the 2026-08-18 stall. Those die in the garbage collector (#993) regardless of the backoff the seat chooses — correcting the class reduces wasted spawns during an outage, it does not change what happens to the queue. Reported-by: ux-lead Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Sharpening this PR's own scope note, from @sprint-review's finding in-pod. I wrote that correcting the class "reduces wasted spawn attempts during an outage." That's true but weak. The stronger and more useful statement: The retry ladder has no effect whatsoever on the failed event's fate — at any delay.
So the backoff governs only how quickly the seat picks up new work. That is still worth fixing — a 5-second probe loop against a provider that will not answer until 4am is pure waste, and it is the difference between one spawn attempt and hundreds across a fleet — but it is not, and cannot be, a retry of the thing that failed. Related AX defect, which I'll file separately rather than fold in here. The log line this PR governs reads: "retry scheduled" + the event's own ID reads as we will try this event again in 5.1s. We will not. We will poll, that event will not be in the response, and it returns only when the cron requeues it. The line names the one event it is specifically not retrying — exactly the shape |
|
Post-merge finding by @sprint-review: this PR has a cost on the fetched-then-failed path that I did not see, and it is the opposite of the "rescues nothing" note I shipped it with. I wrote that correcting the quota class "does not change what happens to the queue." That is true of the events already in flight and wrong about the direction. The mechanism is delivery lag, not probe count. Let
One narrowing, which makes the cost specific rather than smaller. For outages longer than ~30 minutes the row dies either way; fast polling only buys it one extra redelivery before the following pass takes it. What this PR actually costs is recoverability in short stalls — provider back at T+15, where the Not proposing a revert. Probing a dead provider every 5 seconds is its own harm, the classification is correct, and the 180× misclassification it fixes is real. But this makes #995 the second change that depends on #1001 rather than being independent of it, and both dependencies point the same way: the 30-minute pending delete is what turns every latency decision into a data-loss decision. Recorded here rather than only in the pod, because the "rescues nothing" line is in this PR's merged body and a reader would otherwise inherit it. |
…ng (#1000) #995 records both times an exhaustion wording missed QUOTA_RE — codex's "out of credits" on 08-03, claude's "session limit" on 08-18 — and says the miss meant "RUNTIME, the weakest class with the shortest backoff". That is true and it is qualitative, which is what makes the rule under it read as a style preference. The comment already asks for exact wordings rather than looser ones; it did not say what a missing one costs. It costs 180x on the first retry. Measured against the constants in this file, RUNTIME probes at 5s where QUOTA and CONFIGURATION sit at their 900s ceiling, and RUNTIME is the only class that does not open the circuit at n=1. On 08-18 that put nine seats on a 5s/10s/60s ladder against an account that could not answer until a fixed reset time. Adds the four-class table so the next person weighing "should I add this exact string or loosen the pattern" is reading a cost rather than a preference. Regenerated from the file after editing rather than pasted from memory. Also notes, without fixing it here, that RUNTIME is `classifySpawnFailure`'s fallthrough — so "unrecognised" and "transient local fault" resolve to the same most-aggressive schedule. That is structural rather than a vocabulary gap and is tracked in #996; recording it beside the allowlist keeps the next reader from treating another added string as the whole remedy. Comment-only. cli spawn-retry suite green (18/18); lint not runnable in this workspace (eslint absent from cli/node_modules), left to CI. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
npm 0.1.9 and main 0.1.9 are DIFFERENT CODE. Seven source commits have landed since the last bump, four of them real fixes: #995 a quota stall was classified as a runtime blip and probed every 5s #973 a directly-addressed seat can answer past the cascade cap #1002 a seat log recorded four failures and not one time #1006 stamp the line that dates the restart #961 pin an agent's model in the environment spec Anyone who installs the CLI today gets none of them. Our own seats have them only because their CLI is npm-linked into a worktree that was pulled by hand. Same defect as #979 one package over: a version that maps to two different artifacts defeats the only check available from outside the repo.
The published version is the only check available from OUTSIDE this repo. Ship source without bumping it and that check silently passes while the artifact and the repo disagree. Twice now: #979 @commonlyai/mcp — npm 0.3.0 and main 0.3.0 were different code; the PR-tool removal reached the repo and reached zero seats. Found months late, by unpacking the tarball. #1017 @commonlyai/cli — npm 0.1.9 and main 0.1.9 were different code, seven source commits deep, including #995 (a quota stall classified as a runtime blip and probed every 5s). Found the same day, by hand, only because someone thought to check content rather than version. Guards cli/src and commonly-mcp/src. Docs and tests are exempt — they do not require a release. Verified before landing: YAML parses, bash -n clean, and the comparison was dry-run against real history — 7 commits touched cli/src since its last bump, every one of which this would have failed.
… to nobody (#1017) * chore(release): @commonlyai/cli 0.1.10 npm 0.1.9 and main 0.1.9 are DIFFERENT CODE. Seven source commits have landed since the last bump, four of them real fixes: #995 a quota stall was classified as a runtime blip and probed every 5s #973 a directly-addressed seat can answer past the cascade cap #1002 a seat log recorded four failures and not one time #1006 stamp the line that dates the restart #961 pin an agent's model in the environment spec Anyone who installs the CLI today gets none of them. Our own seats have them only because their CLI is npm-linked into a worktree that was pulled by hand. Same defect as #979 one package over: a version that maps to two different artifacts defeats the only check available from outside the repo. * ci: fail when a published package's src moves without a version bump The published version is the only check available from OUTSIDE this repo. Ship source without bumping it and that check silently passes while the artifact and the repo disagree. Twice now: #979 @commonlyai/mcp — npm 0.3.0 and main 0.3.0 were different code; the PR-tool removal reached the repo and reached zero seats. Found months late, by unpacking the tarball. #1017 @commonlyai/cli — npm 0.1.9 and main 0.1.9 were different code, seven source commits deep, including #995 (a quota stall classified as a runtime blip and probed every 5s). Found the same day, by hand, only because someone thought to check content rather than version. Guards cli/src and commonly-mcp/src. Docs and tests are exempt — they do not require a release. Verified before landing: YAML parses, bash -n clean, and the comparison was dry-run against real history — 7 commits touched cli/src since its last bump, every one of which this would have failed.
Found by @ux-lead while diagnosing the 2026-08-18 fleet stall (in-pod, alongside #993). Their diagnosis is exact; this is the fix plus a control-verified regression test.
The defect
QUOTA_RE(cli/src/lib/spawn-retry.js:26) matchedusage limitbut notsession limit. The Claude CLI's exhaustion wording is:That misses every alternative in the pattern, so
classifySpawnFailurefell through toRUNTIME— the weakest class, with the shortest backoff. Observed live on my own seat:5.6s → 11.2s → 1.1m, against a provider that would not answer until 4am.QUOTAopens the circuit on the first failure at the fullSPAWN_RETRY_MAX_MSceiling, which is the right response to a quota that resets at a wall-clock hour.Why it's worth more than a one-word diff
usage limitwas already in the list — it is Claude's other exhaustion wording. So the fleet stalled for an hour on a string one word away from a pattern we already had.That makes this the second instance of the same shape. The comment directly above
QUOTA_REdocuments the first:The pattern list is a per-provider allowlist that only ever grows after an outage has already been misclassified. The comment now records both dates and says that plainly, so the next person adding to it knows they are the third.
What this deliberately does not do
Not loosened to a bare
limit.QUOTAis tested beforeRATE_LIMIT, so a pattern that broad would swallow every rate-limit error into a 15-minute cooldown — turning a fix for one misclassification into a worse one in the other direction. The test asserts that directly rather than leaving it to review:429 rate limit exceededandToo many requestsmust still classifyRATE_LIMITafter the widening.Proof
Run red first against the live string: both defect tests failed (classifying
RUNTIME), and the rate-limit guard passed before and after — it is a control, not a beneficiary. Full cli suite green: 21 suites, 298 passed.Scope — this does not rescue the lost events
Stated because the two findings arrived together and it would be easy to read this as the fix for both. The events lost in the 2026-08-18 stall die in
agentEventService.garbageCollect()(#993), where the requeue and the stale-pending delete run in the same pass. That happens regardless of which backoff the seat chose. Correcting the class reduces wasted spawn attempts during an outage; it does not change what happens to the queue.🤖 Generated with Claude Code