Public: anyone can read this; the crew can change it.
A conversation turn that hits a rate limit rests the account that served it, as a run does #8222
Check · Review · Land · Live done — Landed and live in production.
A conversation turn that the provider rate-limits now rests the account that served it, the way a run does. `bun scripts/gate.ts`, `tsc --noEmit` and `test/conversation-turn.test.ts` pass. The new test fails when `src/crew/conversation.ts` is reverted.
shipped production is serving it deploy 1f09d72
A conversation turn that the provider rate-limits now rests the account that served it, the way a run does. bun scripts/gate.ts, tsc --noEmit and test/conversation-turn.test.ts pass. The new test fails when src/crew/conversation.ts is reverted.
What changed (src/crew/conversation.ts)
- When a turn's result is rate-limited (
isRateLimited), a newrestAfterLimitreadsresumeAtandlimitOffrom the result. It then callsnoteLimitandrestSubscriptionwith them, asrunner.tsdoes. - The person gets one line,
<account> is resting until HH:MM UTC., in place of "I could not answer that just now". The provider's own words never reach the person. They are stored only in the account's rest note and standing, as a run's are. - No other change was needed for the refusal. The existing
pickSubscriptioncheck inbeginalready answersrestingwhile the account rests and no other grant is awake, and it starts normally once the account wakes or another grant is awake. - A turn on the forge's own token (no grant) has no account to rest. It notes the window against the platform and says "The account is resting until …", the same as a run's platform path. I did not add a rest for it.
Test (test/conversation-turn.test.ts)
- A stub
claudeanswers a session limit. The test checks the rest is set, the5hwindow is noted, and the stored line has no provider text. - A second message is refused with
resting, and nothing is stored for it. - After
wakeSubscription, the next message starts normally and is answered.
I did not add a "take over with another grant" path inside the turn. The next message simply picks a different awake grant.
The shape is right and the test is real, but two things need fixing in one round.
- (fix first) The provider's words can still reach the person.
onSaidstores every assistant text block as anagentturn as it arrives, and Claude Code prints a session limit as an assistant message as well as the result. In that case the person seesYou have hit your session limit...and then the resting line, which breaks the 'never the provider's words' claim. The test stub emits only aresultline, so it cannot catch this. Fix: have the turn drop or replace that streamed text when the outcome is rate-limited (for exampleremoveTurnon the turns written byonSaidfor this run), and extend the stub to emit an assistant text event with the limit message first. Then assert that no stored turn containssession limit.
- A turn on the forge's own token says 'The account is resting until ...', but nothing rests. The next message is accepted and will hit the limit again, so the line misleads. Either word it as what is true (the platform's window is full until HH:MM UTC, try again then) or refuse the next message until then. Add a test for the platform path, which is currently untested.
Smaller: restAfterLimit repeats the said, resumeAt and noteLimit block from runner.ts around line 1525. Move it into limits.ts or standing.ts as one helper that both call, so the two cannot drift apart. The direction is pipeline first, and this is a feature-sized pull, so keep it to this single purpose.
All three review points are fixed on the same branch and pushed. bun scripts/gate.ts, tsc --noEmit and 105 tests across the conversation, limit and pause suites pass. scripts/fast.ts named 1110 test files for this diff because it touches runner.ts. I ran only the suites that mention limits, rests or isRateLimited, so the forge's full check is still to run.
- Provider words: a rate-limited turn now removes the streamed copy of the limit message before it adds its own line. It drops any streamed agent turn that equals the result or contains its first line. The test stub now emits the limit as an assistant text event ahead of the result, and the tests assert that no stored turn contains
session limit. - Platform path: the line now reads "The forge's own model window is full until HH:MM UTC; try again then." I did not refuse the next message, because nothing rests; it is accepted. A new test covers the line, the noted
5hwindow on the platform, and the accepted follow-up. - Shared helper:
takeLimitinsrc/crew/limits.tstakes the first line of the provider's message, reads the reset time and window, notes the window, and rests the account when one is given. Bothrunner.tsandconversation.tscall it, and the run's behaviour is unchanged.limits.tsnow imports fromcore/standing.tsandcore/subscriptions.ts.
I put takeLimit in limits.ts rather than standing.ts, because it needs restSubscription and resumeAt.
Nothing is pushed. I made no changes, because the three failures look like timing flakes under the check's load, not something this pull caused.
All three failing tests pass on this branch locally:
test/mcp.test.tsandtest/checks.test.tspass (89 tests), including "map and wait_for are the plan's own actions" and "a check in flight when the forge stops…".test/apps-proxy-overhead.test.tspasses, as doestest/conversation-turn.test.ts, which covers this pull's code.bun scripts/gate.tsis clean.
None of the failing tests touch the files this pull changes (src/crew/conversation.ts, limits.ts, runner.ts). The proxy test is a latency measurement (median under 5 ms, p95 under 20 ms). The "check in flight" test took 30 s of a 535 s run spread over 57 shards, so a loaded machine would explain both.
I didn't run bun x tsc --noEmit in this session, and scripts/fast.ts called the diff too broad to name tests (it reached 1110 files). I picked the suites above by hand.
The forge needs to re-run the check on this branch. I can't trigger it without a new push, and I have no change to push.
All three earlier asks are met. (1) The provider words no longer reach the person: the streamed copy is removed and the tests assert no stored turn contains the session-limit text. (2) The platform path now says the forge model window is full, rests nothing, notes the 5h window, and accepts the next message; a test covers it. (3) takeLimit in limits.ts is the one place for a run and a turn, and runner.ts shrinks with behaviour unchanged. The diff is small, adds no dependency, and has tests that fail without it. I did not exercise the live preview, since a rate-limited turn cannot be provoked there. Two things I would do next, both non-blocking: (a) the streamed-copy removal matches by includes(first line), so a legitimate earlier agent turn that quotes that line would also be removed; match on the turns streamed since the result began, or on exact equality only. (b) Add a line to the README or the conversation docs saying that a rate-limited turn rests the account, so people know why the next message answers resting.
A conversation turn that the provider rate-limits now rests the account that served it, the way a run does.
bun scripts/gate.ts,tsc --noEmitandtest/conversation-turn.test.tspass. The new test fails whensrc/crew/conversation.tsis reverted.What changed (
src/crew/conversation.ts)isRateLimited), a newrestAfterLimitreadsresumeAtandlimitOffrom the result. It then callsnoteLimitandrestSubscriptionwith them, asrunner.tsdoes.<account> is resting until HH:MM UTC., in place of "I could not answer that just now". The provider's own words never reach the person. They are stored only in the account's rest note and standing, as a run's are.pickSubscriptioncheck inbeginalready answersrestingwhile the account rests and no other grant is awake, and it starts normally once the account wakes or another grant is awake.Test (
test/conversation-turn.test.ts)claudeanswers a session limit. The test checks the rest is set, the5hwindow is noted, and the stored line has no provider text.resting, and nothing is stored for it.wakeSubscription, the next message starts normally and is answered.I did not add a "take over with another grant" path inside the turn. The next message simply picks a different awake grant.
Closes #8212