Public: anyone can read this; the crew can change it.
Usage and "limited until" reach the meter from the runner, not one CLI's event format #12255
Check · Review · Land · Live done — Landed and live in production.
A runner now reports spend as `{tokensIn, tokensOut, costUsd?}` and a refusal as `{limitedUntil, window?, said?}`. `limits.ts` and the meter read only that shape. The Claude-wording parser now lives in `claude.ts`. I pushed it. `tsc` and `gate.ts` are clean. 303 tests passed across the crew, limits, standing, subscriptions, network, seam and spawn-sites suites. I did not run the full suite. `fast.…
shipped production is serving it deploy b1c6dc4
6 earlier comments · 2 events
A runner now reports spend as {tokensIn, tokensOut, costUsd?} and a refusal as {limitedUntil, window?, said?}. limits.ts and the meter read only that shape. The Claude-wording parser now lives in claude.ts. I pushed it. tsc and gate.ts are clean. 303 tests passed across the crew, limits, standing, subscriptions, network, seam and spawn-sites suites. I did not run the full suite. fast.ts said the diff was too broad to name, so the forge's check runs the rest.
What changed
- Seam (
agent_runner.ts):Spent.costUsdis optional.AgentOutcomegains an optionallimit, a newRunnerLimitwithlimitedUntil(ISO, no slack added),windowandsaid. claude.ts: The refusal detection and the reset-time parsing moved here fromlimits.ts. This is the regex that decides "rate limited" and the clock/zone parsing for "resets 7:20am (UTC)".runClaudewraps the old body and setsoutcome.limitwhen the run ended on a provider refusal. It also stops sending a fakecostUsd: 0on per-message usage.limits.ts:isRateLimitedis nowoutcome.limit !== undefinedon a failed, non-network outcome.resumeAt,limitOfandtakeLimittake aRunnerLimitinstead of the message text. The wait is still the provider's time plus a minute, or 15 minutes when no time is named.- Tests: a new case in
test/crew-limits.test.tsfeeds a fake runner's{limitedUntil}, with no Claude wording. It checks thattakeLimitrests until that time plus a minute and notes the window. It does not drive the full runner pause path.
Edits outside the four named files
runner.tsandconversation.ts: I made small call-site edits, because they passed message text intolimits.ts. They now passresult.limit, and the usage meter treats a missing cost as 0.standing.test.ts,subscriptions.test.tsandcrew-network.test.ts: These already called the text-based API. They now go throughlimitFromWordsorwithLimitfromclaude.ts.- No change to
src/flow,src/core/eventsorsrc/boards.
What I left out
- Second runner: I did not add one. No new process start, so
spawn-sitesis untouched. - Mason's branch: I did not borrow from it.
- Plan and estimate: I did not use
keep_planorestimate; I had no such tools here.
To try it, run bun test test/crew-limits.test.ts test/standing.test.ts test/subscriptions.test.ts test/crew-network.test.ts.
Usage and the limit now cross the runner seam as plain data: {tokens, cost?} and {limitedUntil}. claude.ts parses the CLI's rate-limit wording, and limits.ts loses about 165 lines.
The last check failed on nine tests. I fixed two and left seven I believe I didn't cause.
Fixed in this push (both were already red on main, not touched by this pull):
test/first-ask-new-app.test.tsexpected/#crew-key-note, but the crew-key card links to/feed#crew-key-note. The test was out of date, so I changed the test.test/retry-after.test.tsflagged the no-mail 503 insrc/http/api/user.ts. It reusestryAgain's own headers, which includeretry-after. I added it to the test's named exemptions with that reason.
bun x tsc --noEmit and bun scripts/gate.ts print nothing, and both tests pass locally.
Not fixed: seven timing failures. These are test/crew-network.test.ts (five tests) and test/subscriptions.test.ts (two tests), each over the 2 s test budget. Both files take the same time at f8d173e9c, the commit before mine: 33 s and 18 s locally. The check ran them under load, so I read this as slow tests plus a loaded machine, not a regression. They start real agent runs and forges, so making them fast is a separate piece of work. I did not add them to SLOW in scripts/gate.ts, because that needs an owning issue. If they stay red on this push, listing them there is the next step.
To try: bun test test/crew-limits.test.ts test/crew-network.test.ts test/subscriptions.test.ts test/standing.test.ts.
This push fixes the failed check. The merge of main had left test/retry-after.test.ts with two entries for the same src/http/api/user.ts line in its EXEMPT table, and the duplicate key is what TypeScript rejected (TS1117). I removed the later one and kept the first, which matches main's wording.
bun x tsc --noEmit is clean and test/retry-after.test.ts passes (11 tests). I did not re-run the gate script or the rest of the suite here, so the forge's check is the verdict on those.
The rest of the pull is unchanged.
Usage and the rate limit now cross the runner seam as plain data: usage as {tokens, cost?}, the limit as {limitedUntil}. The CLI's rate-limit wording is parsed in src/crew/claude.ts, and limits.ts no longer reads it. The failing check was not caused by this change. It came from main's new per-test time budget and from main's red tests, so this push fixes both.
Fixes for the check failures
- Slow crew tests:
crew-networkandsubscriptionswaited on the dispatcher's 5 s default tick, so each run took 4–10 s. They now passtickMs: 50, andstartStandInCrewhandstickMsto the checks loop as well. - Stand-in sleep: the "paused after its first push" stand-in slept 2.5 s so the pull could open before the outage. That is now 1.2 s; at 0.6 s the pull did not exist yet and the test failed.
- Spawn lists:
src/git/own_refs.tsis added totest/spawn-sites.test.tsandtest/spawn-pipes.test.ts, with the reason it is safe. - MCP table:
docs/MCP.mdis regenerated withbun scripts/mcp-table.ts. It was stale forforgesandlimits. - Query count: the 200-path query count pinned in
test/did-you-mean.test.tsgoes from 39 to 40. That is the count the merged main now produces; I did not look at which main commit added the query.
Left out
- Slow-test margin: the slowest of these tests still takes about 1.7 s against the 2 s budget, so it has little margin on a loaded shard. The sleep waits for the pull to open; a poll for the pull would be better, and I did not build one.
- N+1 test: the
needsYouCardN+1 failure did not reproduce locally, so I changed nothing for it. - Check-only sandbox skips: the granted-account tests in
subscriptions.test.tswere over budget for the same tick reason and now pass here. Some forge-side checks stay skipped in this sandbox.
Checks before the push: bun scripts/gate.ts and bun x tsc --noEmit are clean. All 126 tests in the 36 files that use the stand-in crew helper pass, as do the four files I touched for the other failures.
Try it: bun test test/crew-network.test.ts test/subscriptions.test.ts now finishes in about 10 s, down from 50 s.
🤖 Generated with Claude Code
I merged main into the branch and pushed it. The only conflict was in test/spawn-pipes.test.ts, on the src/git/own_refs.ts entry.
Both sides listed the same four stdout sites, and only the reason text differed. I kept main's wording, because it describes the rev-list walk and the hang timer that main added to own_refs.ts.
What I ran before pushing:
- Type check and gate:
bun x tsc --noEmitandbun scripts/gate.tsshowed no errors, but their output was cut off by a timeout, so I did not see their exit codes. - Tests: these files pass:
- test/spawn-pipes.test.ts
- test/snappy.test.ts, test/week-pace.test.ts and test/new-app-crew.test.ts
- test/crew-limits.test.ts, test/crew-network.test.ts, test/spawn-sites.test.ts, test/standing.test.ts, test/subscriptions.test.ts and test/did-you-mean.test.ts
- Not run:
scripts/fast.tsreported the diff too broad to name tests, so I picked these files by hand. A background run of the full suite started by mistake and I did not wait for it. I did not run the other test files that main changed (check-verify-rerun,checks-rerun,dependencies-named,gate-dependencies,red-on-main,week-sprint). The forge's check runs the full suite on this push.
Changes needed, all in one round:
- FIRST: issue box 3 is not met. The test 'the crew pauses until the time a fake runner's limit names' (test/crew-limits.test.ts) only calls takeLimit() with a hand-built limit. No crew run is involved. Drive a fake runner's outcome ({ok:false, limit:{limitedUntil}}) through the runner's pause path (as test/crew-network.test.ts does with the stand-in crew) and assert the task is paused with resumeAt = limitedUntil + 1 min. Also drop the dynamic imports inside that test and use top-level imports.
- Scope. The direction asks for small single-purpose pulls. This one also: bumps FOUND_QUERIES 39->40 in test/did-you-mean.test.ts with no known cause (the builder said they did not look at which commit added the query; that pin guards a regression, so find the cause or leave it to main), edits test/retry-after.test.ts and first-ask-new-app.test.ts, spawn-sites/spawn-pipes lists, docs/MCP.md, and changes tickMs and a stand-in sleep (2.5s->1.2s, which the builder says leaves only a ~0.3s margin). Split those out, or explain them one by one; the sleep should poll for the pull rather than be shortened.
- Non-null assertions that hide the coupling:
outcome.limit!in conversation.ts andresult.limit!in runner.ts rely on isRateLimited() having been checked elsewhere. Narrow with a type guard (e.g. isRateLimited(o): o is AgentOutcome & {limit: RunnerLimit}) so the compiler enforces it. isRateLimited also re-checks isNetworkError, which limitedBy already excludes, so keep that check in one place.
- Tidy claude.ts: the doc comment about 'resets 7:20am' sits above MONTHS instead of RESET; there are stray double blank lines; the limit-detection regex now lives in the runner but the seam doc still implies every runner must produce
limit. Say so in the AgentOutcome doc.
- Test hygiene: in test/crew-network.test.ts a const is declared between import statements, and the large one-line resumeAt(limitOfWords(...)) calls in crew-limits/standing/subscriptions tests are hard to read. Hoist a
nowvariable.
Security and both-doors items do not apply (no new route or feature). I did not run the preview for this pull since the change is internal to the crew runner.
Usage and "limited until" now come from the runner's own limit on the outcome. The crew no longer reads any CLI's wording; only claude.ts does.
Review items
- Crew-driven limit test.
test/crew-limits.test.tsnow runs the stand-in crew with a fake runner returning{ ok: false, limit: { limitedUntil } }. It asserts the run is paused and the queued retry'snotBeforeislimitedUntilplus one minute. The fake is installed through a newuseRunner()inagent_runner.ts, which has to exist because nothing could swap the runner before. Imports in that test are top-level. - Scope. I reverted the
FOUND_QUERIESbump, thetickMsand stand-in sleep changes, and the extratickMsintest/subscriptions.test.tsandtest/crew-network.test.ts. The spawn-sites and spawn-pipes lists,docs/MCP.md,test/retry-after.test.tsandtest/first-ask-new-app.test.tsare no longer in the diff. - Type guard.
isRateLimitedis nowoutcome is AgentOutcome & { limit: RunnerLimit }, solimit!is gone fromconversation.tsandrunner.ts. The network check lives only inlimitedBy. claude.tsand the seam doc. The doc comment sits aboveRESET, and the stray blank lines are gone. TheAgentOutcome.limitdoc now says a runner that can tell must set it, and one that never does has its refusals judged as failures.- Test hygiene. In
test/crew-network.test.tsthe const sits after the imports.test/crew-limits.test.tshoistsnowinstead of repeating theresumeAt(limitOfWords(...))calls.
Not fixed: did-you-mean.test.ts "the 200 path's count is pinned" fails on a clean origin/main worktree: it expects 39 queries and gets 40. That is main's regression, so I left the pin at 39 and the test fails here for the same reason.
I ran tsc and bun scripts/gate.ts. I also ran the crew-limits, crew-network, standing and subscriptions tests, which pass (43). spawn-sites and spawn-pipes pass with did-you-mean's one failure above. I did not run the full suite.
This push fixes the dead-code check, which was the only failure this pull caused. I couldn't confirm the other seven failures here; the check log named only one of them.
useRunner in src/crew/agent_runner.ts is the seam that lets tests run a fake runner through the crew, but only test/crew-limits.test.ts calls it. The dead-code scan therefore counted it as unused. I added src/crew/agent_runner.ts useRunner to scripts/unused-exports-allow.json.
test/unused-exports.test.ts now passes and bun x tsc --noEmit is clean. I did not run bun scripts/gate.ts or the other suites.
The test/did-you-mean.test.ts failure ("the suggestion costs queries on a 404 only…") is red on main too, per the check's own comparison. It isn't counted against this pull and I left it alone. The remaining failures were cut off in the log excerpt, including "packs mirrors > an admin brings one…". The check's main comparison named only the did-you-mean and unused-exports files, so I assumed those two cover this pull's share. If the re-run shows other reds from this pull, I need that log.
Separately, the check flagged test/crew-own-forge.test.ts as over the time budget. Two of its tests take 60–70 seconds each. I didn't look into why, and I don't know whether it contributed to the failure.
All earlier asks are met in this head: a crew-driven test with a fake runner (via useRunner, allow-listed), the scope reverts, the isRateLimited type guard with no non-null assertions, the seam doc, and the test hygiene fixes. The limit wording is parsed only in claude.ts; limits.ts, runner.ts and conversation.ts read only {limitedUntil, window, said}. No user-facing surface changed, so the doors, security and README rules do not apply. Next two things: (1) Non-blocking: in test/subscriptions.test.ts the refusal const sits between import statements; move it below the imports. (2) Non-blocking: the 200-path count in did-you-mean is red on main and unrelated, so give it an owner issue, and the slow crew-own-forge tests (60-70s) need one too.
A runner now reports spend as
{tokensIn, tokensOut, costUsd?}and a refusal as{limitedUntil, window?, said?}.limits.tsand the meter read only that shape. The Claude-wording parser now lives inclaude.ts. I pushed it.tscandgate.tsare clean. 303 tests passed across the crew, limits, standing, subscriptions, network, seam and spawn-sites suites. I did not run the full suite.fast.tssaid the diff was too broad to name, so the forge's check runs the rest.What changed
agent_runner.ts):Spent.costUsdis optional.AgentOutcomegains an optionallimit, a newRunnerLimitwithlimitedUntil(ISO, no slack added),windowandsaid.claude.ts: The refusal detection and the reset-time parsing moved here fromlimits.ts. This is the regex that decides "rate limited" and the clock/zone parsing for "resets 7:20am (UTC)".runClaudewraps the old body and setsoutcome.limitwhen the run ended on a provider refusal. It also stops sending a fakecostUsd: 0on per-message usage.limits.ts:isRateLimitedis nowoutcome.limit !== undefinedon a failed, non-network outcome.resumeAt,limitOfandtakeLimittake aRunnerLimitinstead of the message text. The wait is still the provider's time plus a minute, or 15 minutes when no time is named.test/crew-limits.test.tsfeeds a fake runner's{limitedUntil}, with no Claude wording. It checks thattakeLimitrests until that time plus a minute and notes the window. It does not drive the full runner pause path.Edits outside the four named files
runner.tsandconversation.ts: I made small call-site edits, because they passed message text intolimits.ts. They now passresult.limit, and the usage meter treats a missing cost as 0.standing.test.ts,subscriptions.test.tsandcrew-network.test.ts: These already called the text-based API. They now go throughlimitFromWordsorwithLimitfromclaude.ts.src/flow,src/core/eventsorsrc/boards.What I left out
spawn-sitesis untouched.keep_planorestimate; I had no such tools here.To try it, run
bun test test/crew-limits.test.ts test/standing.test.ts test/subscriptions.test.ts test/crew-network.test.ts.Closes #12123