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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

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

opened this

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 new restAfterLimit reads resumeAt and limitOf from the result. It then calls noteLimit and restSubscription with them, as runner.ts does.
  • 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 pickSubscription check in begin already answers resting while 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 claude answers a session limit. The test checks the rest is set, the 5h window 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.

Closes #8212

commented

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 new restAfterLimit reads resumeAt and limitOf from the result. It then calls noteLimit and restSubscription with them, as runner.ts does.
  • 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 pickSubscription check in begin already answers resting while 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 claude answers a session limit. The test checks the rest is set, the 5h window 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.

Changes requested — before the latest push

The shape is right and the test is real, but two things need fixing in one round.

  1. (fix first) The provider's words can still reach the person. onSaid stores every assistant text block as an agent turn as it arrives, and Claude Code prints a session limit as an assistant message as well as the result. In that case the person sees You have hit your session limit... and then the resting line, which breaks the 'never the provider's words' claim. The test stub emits only a result line, so it cannot catch this. Fix: have the turn drop or replace that streamed text when the outcome is rate-limited (for example removeTurn on the turns written by onSaid for this run), and extend the stub to emit an assistant text event with the limit message first. Then assert that no stored turn contains session limit.
  1. 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.

commented

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 5h window on the platform, and the accepted follow-up.
  • Shared helper: takeLimit in src/crew/limits.ts takes 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. Both runner.ts and conversation.ts call it, and the run's behaviour is unchanged. limits.ts now imports from core/standing.ts and core/subscriptions.ts.

I put takeLimit in limits.ts rather than standing.ts, because it needs restSubscription and resumeAt.

commented

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.ts and test/checks.test.ts pass (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.ts passes, as does test/conversation-turn.test.ts, which covers this pull's code.
  • bun scripts/gate.ts is 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.

Approved

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.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs