Repository navigation
Seed a Claude Code usage-limit/quota env-fault pattern once a real line is captured #610
Description
Follow-up to #323 and #507, whose PR replaced claude.toml's env_fault_patterns. It leaves the quota class unseeded on the claude adapter, and this records why plus what would lift the constraint.
What is missing
src/bmad_loop/data/profiles/claude.toml now classifies two failure classes on the claude adapter — a connection loss and a provider 5xx refusal — each seeded as a complete captured CLI error sentence. It classifies no subscription usage-limit refusal. So when an Anthropic plan's usage limit stops a session, the run still charges a dev attempt, exhausts max_dev_attempts and defers a story whose spec was fine. That is the same failure #323 reported on another provider, and it is still unshipped on this adapter.
Why it was not done in the #323/#507 PR
There is no captured Claude Code usage-limit line to seed from.
Searched on 2026-08-15:
- this repo's tracked tree;
- issues Dev session lost to an API/transport failure is charged as a story timeout, exhausting the attempt budget #194, Provider usage-limit / quota errors are not classified as environment faults — the loop burns retries and defers healthy stories #323 and claude.toml's env_fault pattern false-positives on ordinary prose — including this repo's own CHANGELOG and docs #507, bodies and comments;
- 2681 local Claude Code JSONL transcripts under
~/.claude/projects(2684 across all of~/.claude).
usage limit reached and limit will reset do hit in the transcripts — 33 and 31 files respectively — but every hit is a citation, not an emission: issue #323's own AI_APICallError: Usage limit reached for 5 hour… line, which is opencode's provider text rather than Claude Code's; this repo's tests/test_env_fault_patterns.py corpus; the withdrawn draft pattern; and agent sessions discussing exactly those. None is a line Claude Code printed.
The standing bar lives in tests/test_env_fault_patterns.py, in test_unseeded_profiles_stay_inert's docstring: seed from a captured log line with the run it came from cited, never from a plausible-looking string. An unverified pattern is not a neutral bet — one that fires on a healthy session pauses the entire run, which is worse than the fault it was meant to catch.
Why the vocabulary cannot simply be added
The claude adapter's scanned log is a tmux pane capture (EnvFaultMixin.ENV_FAULT_LOG_SUFFIX), so it carries the model's own output. 429, rate limit, quota and usage limit are exactly what a story that implements rate limiting prints all day — in test names, fixtures, banner copy, acceptance criteria and diff hunks.
That is measured, not assumed. Compiling the narrowest plausible quota anchor, API Error: 429, against the corpus already in tests/test_env_fault_patterns.py false-positives on 3 lines sitting in ANCHOR_REACHING_BAIT today:
// TODO: surface "API Error: 429" to the user with a Retry-After hint
expect(banner).toBe("API Error: 429 rate_limit_error")
- [ ] AC-2: map API Error: 429 to a friendly rate limit message
A slightly looser form (the framing token plus up to 80 characters plus quota vocabulary) takes it to 4. Every one of those is ordinary healthy-session output, and every one would pause a working run.
This is the same trap #507 documented from the other side: widening a cause list onto a loose anchor makes the false-positive half worse, because quota vocabulary is far more common in ordinary story text than ECONNREFUSED is.
An option that would remove the constraint — offered as an option, not a commitment
Point the claude profile's env-fault scan at Claude Code's own JSONL transcript instead of the pane capture, and anchor on the structural key "isApiErrorMessage":true rather than on error vocabulary at all.
Why that anchor differs in kind: inside the transcript a model's own words live within a JSON string, so a citation of the key is escaped — \"isApiErrorMessage\" — while the structural key is not. A citation cannot physically reproduce the anchor. That is the property #507 asked for, and it is not available on a pane capture at all.
Measured across the same 2681 transcripts:
| form | occurrences | files |
|---|---|---|
unescaped structural key "isApiErrorMessage":true |
11 | 7 |
JSON-escaped citation \"isApiErrorMessage\" |
46 | 4 |
Zero file overlap. And the 11 structural occurrences carry only 4 distinct texts, all genuine CLI errors:
API Error: 500 Internal server error…API Error: 529 Overloaded…API Error: Connection closed mid-response…- a model-selection error (
There's an issue with the selected model…)
— despite those same files containing plenty of prose citing provider errors.
The cost, stated honestly, because it is not small:
- a new per-profile field naming which source the env-fault scan reads;
- a scan-source seam in
EnvFaultMixin:_env_fault_evidence()takes only atask_idtoday and derives the path fromENV_FAULT_LOG_SUFFIX, whereas the transcript path arrives later, on theSessionResult; - porting the ~15
test_classify_env_fault_*tests intests/test_generic_tmux.py, which write their fixture to whatever path the code resolves; - it is
claude-specific — no other pane-capture profile has an equivalent structural transcript.
Worth weighing too: the transcript is written by the CLI, so repointing trades one soundness argument for a different one, and that argument has to be re-derived rather than inherited.
What to do when a line IS captured
- Add the captured line to
CLAUDE_REALintests/test_env_fault_patterns.py, citing the run it came from. - Extend
claude.toml'senv_fault_patternswith a pattern reproducing that complete sentence, keeping a single-character class so the pattern does not match its own source line. - Re-run the corpus —
test_seeded_profiles_never_classify_healthy_model_output,test_seeded_profiles_survive_bait_that_reaches_their_anchor,test_shipped_patterns_do_not_match_their_own_profile_lineandtest_pane_capture_patterns_do_not_match_this_repoare the gate.
Context: #323 (the quota-miss half) and #507 (the false-positive half).
Activity
coderabbitai commentedon Aug 15, 2026
🔗 Related PRs
#239 - feat(adapters): coding-CLI adapter registry (Seam A) — out-of-tree adapters with zero core edits [merged]
#354 - fix(worktree): re-register the relay over a seeded hook config [merged]
#522 - fix(adapters,engine): tell a lost mux session apart from an exited CLI [merged]
#605 - fix(deferredwork): bound an entry at every CommonMark ATX heading (
#516) [open]#609 - fix: close the codec except-family holes in the diag writer, tmux captures, and plugin hooks (
#380,#383) [open]📝 Issue Planner
Check the box below or use the
@coderabbitai plancommand to generate an implementation plan and prompts that you can use with your favorite coding assistant.🧪 Issue enrichment is currently in open beta.
You can configure auto-planning by selecting labels in the issue_enrichment configuration.
To disable automatic issue enrichment, add the following to your
.coderabbit.yaml:💬 Have feedback or questions? Drop into our discord!
sirendhead commentedon Sep 7, 2026
Captured lines — two real Claude Code quota sentences from unattended color-dev-loop runs (bmad-loop 0.11.1, claude profile, psmux on Windows 11, Claude Code classic renderer), verbatim from the ANSI-stripped pane logs:
Where (both files are pane captures the engine wrote itself):
20260907-084618-824f.bmad-loop/runs/20260907-084618-824f/logs/TASK-2732-dev-1.log20260907-084618-824f.bmad-loop/runs/20260907-084618-824f/logs/TASK-2732-review-1.log20260907-090132-98e3.bmad-loop/runs/20260907-090132-98e3/logs/TASK-1735-dev-{1,2}.logObserved engine behaviour without a seeded pattern: the session goes idle, the stall wake-nudge fires every ~10 min ("You appear idle in bmad-loop automation mode…"), each nudge reproduces the same quota line, and the session is on its way to
dev_stall_nudges_cap→ a charged attempt (review-1 on skills-library, dev-2 on prompts-hub — dev-1 had already been consumed the same way). I hard-stopped both runs to keep them resumable.Two distinct causes, worth two alternatives with the profile's own single-character-class trick so the profile line does not match itself:
You're out of usage credit[s]\. Run /usage-credits— credit exhaustion (this one is a money gate: no re-arm will succeed until the operator tops up or switches model).You've hit your session limi[t] · resets— the 5-hour window; a pause + re-arm after the reset time is the right shape.Note the second sentence carries a reset time; a future refinement could parse it into the pause reason. Happy to open the PR (pattern +
test_shipped_patterns_do_not_match_their_own_profile_linepins) once you confirm this is the evidentiary bar you wanted.sirendhead commentedon Sep 9, 2026
Captured line + PR: #774 seeds
"You're out of usage credit[s]"insrc/bmad_loop/data/profiles/claude.tomlenv_fault_patterns(single-character-class trick like the existing entries), with the real line verbatim in the comment —You're out of usage credits. Run /usage-credits to keep using Fable 5.1 or /model to switch models.— captured on 2026-09-07 in two unattended runs (four Claude Code sessions idled through the stall-nudge cap because nothing matched). Test:tests/test_env_fault_patterns.pygets the line inCLAUDE_REALplus three near-miss bait lines; ablation (pattern removed) fails exactlytest_each_seeded_profile_catches_its_own_cli_error_lines[claude-You're out of usage credits…]. The siblingsession limitline stays unseeded (separate wording, not captured verbatim here). Prepared by a Claude Code sonnet subagent on the operator's go, from fork sirendhead/bmad-loop.nidelson commentedon Sep 14, 2026
Second independent capture of the
session limitsibling — different OS, different multiplexer, same sentence. Offered as the verbatim line #774's description says is still missing, plus one distinction the pattern has to survive.Captured line
bmad-loop 0.11.1,
claudeprofile, macOS 26.6.2 + tmux (the earlier capture was Windows 11 + psmux), engine-written pane log, ANSI-stripped:20260913-165153-b850(story 5a).bmad-loop/runs/20260913-165153-b850/logs/5a-dev-2.log(3,837,518 bytes)Matching the line reported on 2026-09-07 from
psmuxon Windows, minus the parenthesised timezone. The reset time is present in both; the timezone suffix is not stable across renderers, so an anchor that stops at· resetscovers both captures.The distinction the pattern must survive: the same session prints a warning family
This is the part I did not see recorded yet. The same pane, in the same session, carries two families built from nearly identical words:
The advisory family outnumbers the real fault 13 to 2. A session printing
You've used 90% of your session limitis working normally — pausing the run there would be exactly the false-positive hazard #507 is about, and it would fire on the majority of occurrences.The proposed anchor handles this correctly. Compiled against all four distinct forms from our log:
hit yourvsused N% of youris what separates them; an anchor loosened tosession limit · resetswould match all four. Worth a bait line intest_env_fault_patterns.py— the advisory is a near-miss that a healthy run emits repeatedly, not a hypothetical one.Engine behaviour without the pattern — a different shape from the 2026-09-07 report
The earlier report had the session going idle and the stall wake-nudge firing every ~10 min toward
dev_stall_nudges_cap. Ours never idled: zeroYou appear idle in bmad-loop automation modein the pane, zero stall/nudge events in the journal.The session kept the pane alive throughout, so stall detection had nothing to arm on. What killed it was
session_timeout_min: the clock had been running through the whole blocked stretch, so when the window reset at ~19:11 only 17 of the 90 minutes were left. The story deferred with its work uncommitted; it had to be recovered by hand.So on this adapter the class costs a full dev leg (3,204,837 weighted tokens, ~32% of the run's total) even when the session never idles and does come back by itself.
One observation about the reset time, for whatever it's worth
Both captures carry a reset time in the sentence itself (
resets 7:20pm,resets 12:50pm (Asia/Ho_Chi_Minh)), and the window really does reopen at that time — ours did, unattended. That is a stronger signal than the credit-exhaustion sibling, which needs an operator. If the pause reason ever parses it, the re-arm has a known deadline rather than a poll.Whether the elapsed blocked time should also stop counting against
session_timeout_minis a separate question from seeding the pattern, and I don't want to conflate the two here — happy to open that separately if it isn't already covered by something I missed.Raw evidence is a pane log the engine wrote itself; can quote more context around any of the four forms if useful.
Merge pull request #999 from bmad-code-org/fix/610-claude-usage-limits
fix(adapters): pause a Claude session blocked by a usage limit instea…