Skip to content

fix(cli): a quota stall was classified as a runtime blip and probed every 5s - #995

Merged
lilyshen0722 merged 1 commit into
mainfrom
fix/quota-classifier-session-limit
Aug 18, 2026
Merged

fix(cli): a quota stall was classified as a runtime blip and probed every 5s#995
lilyshen0722 merged 1 commit into
mainfrom
fix/quota-classifier-session-limit

Conversation

@lilyshen0722

Copy link
Copy Markdown
Contributor

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) matched usage limit but not session limit. The Claude CLI's exhaustion wording is:

claude exited with code 1: You've hit your session limit · resets 4am (America/Los_Angeles)

That misses every alternative in the pattern, so classifySpawnFailure fell through to RUNTIME — 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. QUOTA opens the circuit on the first failure at the full SPAWN_RETRY_MAX_MS ceiling, 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 limit was 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_RE documents the first:

out of credits is codex's exact wording for an exhausted workspace balance … Without it that outage classified as RUNTIME and drew the shortest backoff — observed live on 2026-08-03 before this pattern was added.

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. QUOTA is tested before RATE_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 exceeded and Too many requests must still classify RATE_LIMIT after 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

…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>
@lilyshen0722

Copy link
Copy Markdown
Contributor Author

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.

  • AgentEventService.list() hard-filters status: 'pending' (agentEventService.ts:1094).
  • A failed spawn leaves the event delivered — the wrapper's only event-state call is the ack at agent.js:1109. There is no nack and no release. (claimKeeper.release() at :881 is the ADR-018 message claim, a different mechanism on a different object.)
  • So from the moment a spawn fails, that event is invisible to every subsequent poll by construction. The seat cannot re-fetch it whether it probes in 5.1s or in 15 minutes.
  • Only garbageCollect()'s requeue pass can move it back to pendingdeliveredAt < now-10min on a */10 cron, which its own comment describes as uniform over 10–20 min, mean ~15.
  • And if it is older than the 30-minute stale threshold when that requeue fires, the delete in the same pass destroys it (garbageCollect() requeues events to 'pending' and deletes them in the same pass — deterministic for any delivery lag ≥20min #993).

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:

… (1 consecutive) — event 6a842eb896408f264d9a4846 remains unacked; retry scheduled, next probe in 5.1s

"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 docs/development/agent-experience-audit.md exists to record.

@lilyshen0722

Copy link
Copy Markdown
Contributor Author

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 L = time from enqueue to first fetch. The GC requeues a stuck delivered row at deliveredAt + 10..20 (10-minute gate, */10 cron) and deletes pending rows older than 30 minutes in the same pass. So the row is destroyed at its own requeue iff:

L + 10..20 > 30      ⇔      L > 10..20
  • RUNTIME ladder (5.6s, 11.2s, 1.1m, …): L ≈ 0 early in a stall. Requeue lands at age 10–20 — under the line. The row survives that pass and is redelivered.
  • QUOTA ceiling (900s from failure Add basic unit tests for backend functions #1, this PR): L up to 18 min. Requeue lands at age 28–38 — over the line on most cron landings. The row is destroyed by the pass that was supposed to rescue it.

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 RUNTIME ladder had the event requeued and retryable and this one has not fetched it yet.

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.

lilyshen0722 added a commit that referenced this pull request Aug 18, 2026
…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>
lilyshen0722 added a commit that referenced this pull request Aug 19, 2026
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.
lilyshen0722 added a commit that referenced this pull request Aug 19, 2026
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.
lilyshen0722 added a commit that referenced this pull request Aug 19, 2026
… 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant