Repository navigation
feat(agent): admin lane, final run limits and anonymous limit telemetry - #12507
Merged
Merged
Conversation
The admin's runs get limits of their own: five in flight, no rate window, five per admin, and no per-user day caps or message minute. The BFF asserts the tier with X-Anyplot-Tier from the verified admin identity; anything else is the public tier, which keeps today's limits. The global day budget rises to 80 M and binds every tier. /v1/status reports the caller's own cap, the gates run off the event loop, Claude calls retry four times, and a 429 that outlasts them ends a run as busy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
From the Gemini Flash pre-check: model_busy (was rate_limited) also counts Anthropic's 529 OverloadedError, and the pipeline_result line names the innermost error class instead of ADK's DynamicNodeFailError. An implicit __context__ is still not followed. A route-level test pins that the attack strikes lock an admin out too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
Two moderate active findings remain: Cloud Run concurrency can reject admitted streams before the queue’s documented capacity response, and model-service exhaustion is misreported as queue saturation.
2 open issues
What changed in this PR
This pull request adds a trusted administrator execution lane for agent fan-outs while preserving public-user limits. It also improves model-busy handling, render telemetry, frontend status messaging, deployment capacity, documentation, and test coverage.
Changes:
- Adds tier-aware queue lanes, quotas, daily-budget exemptions, and BFF-to-agents tier propagation.
- Classifies exhausted model retries as
busy, increases Claude retries, and moves render gates to worker threads. - Updates fan-out UI behavior, status reporting, Cloud Run concurrency, documentation, changelog, and tests.
Review findings:
- Moderate (2 votes):
agents/cloudbuild.yamlmay admit more streams than Cloud Run concurrency can handle; aggregate admin waiting streams or increase capacity accordingly. - Moderate (2 votes): Some pre-pipeline model 429/529 failures still surface as
capacityrather thanbusy; preserve the distinction in the stream error and UI. - Nit (1 vote): Clarify in
docs/concepts/agent-network.mdthat the global token budget is only the admin’s remaining daily model-related cap. - Nit (1 vote): Rename the grammatically incorrect
test_an_admins...test.
| File | Description |
|---|---|
tests/unit/api/test_agent_router.py |
Verifies trusted admin-tier headers and protection from client-supplied values. |
tests/unit/agents/test_settings.py |
Tests admin lane settings and the increased global budget. |
tests/unit/agents/test_image.py |
Updates the expected Cloud Run concurrency setting. |
tests/unit/agents/runtime/test_telemetry.py |
Updates telemetry tests for tier-aware message handling. |
tests/unit/agents/runtime/test_run_queue.py |
Tests lane limits, capacity, ordering, isolation, and per-user behavior. |
tests/unit/agents/runtime/test_admin_lane.py |
Covers admin tiers, exemptions, busy failures, lane isolation, and worker-thread gates. |
tests/unit/agents/runtime/fakes.py |
Allows model fakes to raise configured exceptions. |
docs/reference/api.md |
Documents tier headers, status fields, limits, and busy errors. |
docs/concepts/agent-network.md |
Describes admin lanes, quotas, rendering contention, and deployment capacity. |
changelog.d/agent-admin-lane.md |
Records the admin lane and related model retry changes. |
app/src/sections/agent-chat/messages.ts |
Adds busy and dynamic run-limit messaging. |
app/src/sections/agent-chat/fanoutRules.ts |
Displays detailed plot failure messages in fan-out cards. |
app/src/sections/agent-chat/fanoutRules.test.ts |
Tests busy failure presentation. |
app/src/sections/agent-chat/FanOut.tsx |
Documents caller-specific fan-out concurrency. |
app/src/sections/agent-chat/ChatThread.test.tsx |
Tests dynamic run-limit and busy messaging. |
app/src/pages/AgentChatPage.tsx |
Uses the caller’s reported run limit for fan-out. |
app/src/lib/agent.ts |
Extends agent status and stream types with tier-aware fields. |
api/routers/agent.py |
Asserts and forwards the verified admin tier. |
agents/README.md |
Documents admin settings, limits, and deployment concurrency. |
agents/main.py |
Applies tiers to status, ledgers, queue lanes, and busy errors. |
agents/cloudbuild.yaml |
Raises agents service Cloud Run concurrency. |
agents/anyplot/tools/session.py |
Applies tier-aware data-run limits. |
agents/anyplot/theme_render.py |
Moves theme-render gates off the event loop. |
agents/anyplot/telemetry.py |
Adds busy as a pipeline failure reason. |
agents/anyplot/settings.py |
Adds admin lane settings and raises the global token budget. |
agents/anyplot/schemas.py |
Adds busy to the public failure-reason schema. |
agents/anyplot/run_queue.py |
Implements independent admin, premium, and normal queue pools. |
agents/anyplot/render/serial.py |
Records render-slot wait time. |
agents/anyplot/render/contract.py |
Carries render-slot wait telemetry. |
agents/anyplot/plugins/scope_guard.py |
Applies tier-aware daily budget checks. |
agents/anyplot/plugins/ledger.py |
Implements admin exemptions and tier-aware usage accounting. |
agents/anyplot/plugins/budget.py |
Applies tier-aware pipeline-run limits. |
agents/anyplot/pipeline.py |
Runs gates in worker threads and classifies model overloads as busy. |
agents/anyplot/models.py |
Adds Claude retry configuration and model-busy detection. |
Review effort: Lite
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
From the Copilot review of #12507: a lane without a rate window now holds as many waiting entries as it has slots (5 for the admin lane, 50 before), so both lanes at their fullest hold 21 streams, within Cloud Run's concurrency of 30. The page's capacity text now also names an overloaded model service, which a root call refused after its retries ends in. The docs say the global budget is an admin's only daily cap on model spend. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
Owner decisions of 2026-11-01, superseding the first admin lane: five runs in flight for the whole instance and one start every 10 s for every lane (AGENT_RUNS_PER_MINUTE 6, the starts spaced 60/6 s apart), so the queue holds 60. The admin lane keeps only its cap of 5 runs (public 2) and its exemptions. Public day caps are 50 runs and 10 M weighted tokens (about $1); the global budget is 100 M (about $10). Cloud Run concurrency goes to 80 for the queue's 60 waiting streams. pipeline_run gains lane, queue_wait_ms, queue_position, user_runs_today and user_tokens_today: closed sets and bounded integers, no user id, no hash. The views SQL reads them (not applied in BigQuery), and a test pins every model field to its view column. A queue test runs a 15-library fan-out of one admin at the new values: all start, none is refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
The admin's cap of five runs counts every window, while each fan-out panel counts only its own: with .plot_all() open in three windows the server took five runs and refused the rest, and the panels marked those libraries failed. A run_limit now sends the library back to waiting and shrinks the panel to the runs the server took; a freed place or a 15 s retry tries one library again, and a library gives up only after 30 minutes. A simulation of three 15-library panels sharing one cap of five finishes all 45 plots. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
From the Gemini Flash pre-check of the follow-ups: FanOutRun clears its in-flight mark and frees the place also when plotTurn rejects. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
MarkusNeusinger
added a commit
that referenced
this pull request
Nov 1, 2026
## Summary - Cuts v3.6.0 "All fifteen": `tools.changelog release` folds the 95 fragments of the window into `## [3.6.0] — 2026-11-01 — All fifteen` (170 entries: Added 63, Changed 57, Removed 2, Fixed 42, Security 6), repoints the compare links and bumps `pyproject.toml`, `uv.lock` and `app/package.json` to 3.6.0. - The window is 253 squash-merged PRs since v3.5.0 (#12160 to #12506). The milestone: the agent chat ("Use with my data") serves all 15 libraries for admins, plus today's user-test fixes #12502 to #12506. #12507 and the open sample-data, combo-request, date-convert and data-grid work are not on main and not in this release. - Hand-written aggregate lines: the `**Dependencies:**` bullet (14 Dependabot PRs; four security updates close 60 advisories, four critical and 32 high) and the italic `*Catalog:*` line (no new implementations or specs; 138 regenerations, 127 of them the MUI X 9 migration, 11 daily-regen improvements; 3 stored re-scores; coverage unchanged at 315 of 325 specs). Routine decisions, documented here: - Ten fragment bullets had no PR reference (`/pull_request` step 7 never ran for #12404, #12408, #12409, #12235, #12458, #12461, #12462). Before the cut, their real numbers were appended (found with `git log --diff-filter=A` on each fragment), and the two Jules fragments (#12408, #12409) were wrapped to the file's line width without changing their wording. - Completeness: every non-bot commit in `v3.5.0..origin/main` has an entry except #12469, #12471 and #12472 (`skip-changelog`; a temporary DEBUG diagnosis and its revert, net zero) and #12298 (`plots/`-only metadata attribution, exempt). No fragment was added. - Catalog count: implementation-file changes in the window, classified by whether the file existed at `v3.5.0` (all did, so no new implementations), cross-checked against the merged pipeline PRs' labels on GitHub (127 `regen:forced`, 11 `regen:improved`, 3 `review-writeback`); the MUI X split comes from `library_version` and `generated_by` in `plots/*/metadata/javascript/muix.yaml`. Advisory counts are unique GHSA ids of the Dependabot alerts fixed after the v3.5.0 tag; the nanoid and `@vitest/mocker` alerts closed by #12235 and #12249 belong to those PRs' own entries and are not counted. ## Plan `agentic/commands/release.md`, steps 1 to 5. The tag and the GitHub release (steps 6 and 7) follow after the merge. ## Test plan - [x] `uv run python -m tools.changelog release 3.6.0 --title "All fifteen" --dry-run`, then the real run - [x] The diff touches only `CHANGELOG.md`, `pyproject.toml`, `uv.lock`, `app/package.json` and the 95 deleted fragments - [x] `uv lock --check` passes; the lock changes only the project's own version line - [x] `uv run pytest tests/unit/test_version_sync.py` passes (2 tests) - [x] `uv run python -m tools.changelog check --base origin/main` passes - [ ] CI green https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Three windows of .plot_all() copy 45 datasets within a minute, past the copy limits of 30 a minute and 150 a day. The store's byte cap still refuses a copy it has no room for, and public users keep both limits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
4 of 5 tasks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
MarkusNeusinger
added a commit
that referenced
this pull request
Nov 2, 2026
Carries the caller's tier into the pause: `day_refusal` takes it, so an admin, who has no own day budget, hears `service_paused` from the route, the scope guard, the Budget plugin and the paste routes. The usage book learns who came in the admin tier from `_stamp`, which every spending route passes, and the live view counts an admin only at the caps that bind every tier. The per-day table's queue wait reads `pipeline_run.queue_wait_ms` from the line itself, so it fills from #12507's deploy on, before the `pipeline_runs` view is applied again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
MarkusNeusinger
added a commit
that referenced
this pull request
Nov 2, 2026
## Summary
- `/debug` gets a table of the last 14 UTC days (new chats, plots, cost,
fan-out cost, limit hits by code, p95 queue wait), read read-only from
the BigQuery telemetry.
- `/debug` gets a live line for today from the agents service: the
service's spend against its day budget and how many users are at their
own limits, counts only, for the instance's lifetime.
- A spent service budget now has its own refusal, `service_paused`,
apart from a user's own limit. It names the reset at 00:00 UTC, the page
adds the user's own time, and `GET /v1/status` tells the chat before the
user types.
## Plan
N/A: owner request of 2026-11-02 ("What happens when the $10 daily limit
is reached? We definitely need a few more numbers in debug: dollars per
day, how many new chats."). This takes over the work of a killed
session.
## Decisions
- **Window.** The brief asked for a window floored to 10 minutes. The
table's window is floored to the day instead, so its oldest row is a
whole day. The query text and parameter stay the same all day, and the
API keeps each answer for 10 minutes as the card does.
- **`budget` counts too.** Limit hits count `budget` as well as the
codes the brief named, because a user's own spent token budget is a
limit hit too. `locked_out` is a safety lockout, so it stays in the
card's refusal list.
- **Source handling.** `live` lines fill every column, `fanout` lines
fill only a separate `fan-out` cost column (they spend the same day
budget), and `harness` lines are never read.
- **A user with both budgets spent keeps `budget`.** The pause text says
their own limit is untouched, which would be false for them.
- **The dollars carry `eu`'s 10 %.** The live line prices weighted
tokens at the main model's input list price in the service's location,
so the default of 100 M tokens reads `$11.00`, not `$10.00`.
- **The refusal text stays in UTC on the server.** The server does not
know the user's time zone, so the page adds the local time next to it.
`/debug` shows both.
- **A pipeline halted mid-run keeps `plot.reason = budget`.** That is
the `plot` event's closed failure reason, not a refusal, and it reads
"the usage limit is reached", which is true of either budget. The root's
next call then sends `service_paused` through `day_refusal`.
## Flash pre-check
- First pass: stopped at its token limit without a verdict, after
confirming that `daily_sql` prices and deduplicates as
`05_prices_cost.sql` does.
- Second pass, narrowed to the remaining questions: "sound with
changes", 3 findings, none applied. The pipeline finding claims the
refusal stays `budget`, which the code does not do (see above). The
double-count finding refutes itself in its own text. The client-side
reset time is always the next 00:00 UTC, so passing the server's value
would change nothing for a correct clock. It found no gap in the
consumers of the new code, no exposure beyond counts and times, and no
bug in the pause note.
## Copilot review
Three findings, one applied:
- **Applied:** the page's pause note claimed "your own limit is not
affected", which `/status` cannot know. The note now says only that the
chat is paused and until when. The server's refusal keeps the sentence,
because `day_refusal` sends it only to users with their own budget left.
- **Skipped:** the fragment's PR references. `/pull_request` step 7
appends them and the changelog check passes.
- **Done after #12507 merged:** the admin tier in `snapshot` and
`day_refusal`; see the next section.
## Merged with #12507
- **`day_refusal` takes the caller's tier.** An admin has no own day
budget, so the route, the scope guard, the Budget plugin and the paste
routes all answer an admin `service_paused` during a pause, never
`budget`.
- **The live line counts an admin only at the caps that bind every
tier** (pastes, conversions, strikes). The usage book holds no tier, so
it learns who came in the admin tier from `_stamp`, which every spending
route passes (`UsageBook.mark_admin`).
- **The queue-wait column reads `pipeline_run.queue_wait_ms` from the
line itself**, not from the `pipeline_runs` view, so it fills from
#12507's deploy on, whether or not the view is applied again, and reads
`—` on every earlier day. A test pins the field name to the telemetry
model.
## Owner steps
None. No view changes, no setting changes, no writes to BigQuery.
## Verification
- BigQuery, read-only: per-day cost matches the `cost` view for every
day of the window (10 day and source rows, to the rounding).
`read_daily` over the real REST path decodes 14 rows.
- Scan: one load processes 62,676 bytes in 9 partitions in 0.6 s (a real
uncached job; BigQuery bills its 10 MiB minimum). Dry runs of the same
query: 0 bytes for a window in the future, 50,674 for 3 days, 62,676 for
the 14 days, 70,906 for the whole bucket.
- Browser, own headless playwright-core, 1440x900 and 390x844, light and
dark, normal and paused, with the real rows: no console errors, no page
overflow. The mobile check found the limit-hits cell wrapping and a
mixed UTC and local clock; both are fixed.
- Gates after the #12507 merge: ruff, ruff format, mypy, 26,034 unit
tests, and the app's lint, fm:check, type-check, 1,379 tests and build.
## Test plan
- [x] Unit tests: the query builder, the aggregates, the live status
payload, the pause refusal against the personal one, and the paste's
`429`.
- [x] Per-day cost matches the `cost` view in BigQuery.
- [x] Dry run shows that one load scans only the window's partitions.
- [x] `/debug` and the chat's pause note in the browser, both viewports
and both themes (the note's shorter final wording is covered by unit
tests).
- [ ] After the deploy: open `/debug` and check that the table and the
live line load.
https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Summary
AGENT_RUN_CONCURRENCY, was 1) and starts at most one every 10 s, in every lane (AGENT_RUNS_PER_MINUTE6, with the starts spaced 60/6 s apart,RunQueue.start_gap_s), so it holds 60 waiting entries. Lanes differ only in their per-user cap and order: public 2 (AGENT_USER_MAX_RUNS, was 3), admin 5 (AGENT_ADMIN_USER_MAX_RUNS), andpremiumstays reserved and goes first. A public user's day is 50 runs and 10,000,000 weighted tokens (about $1). The global budget is 100,000,000 (about $10) and binds admins too. Admins are exempt from the per-user day caps and the message minute; theattackstrikes bind everyone. The values are thesettings.pydefaults, the pinned production values, and Cloud Run--concurrencyrises to 80 for the 60 waiting streams.X-Anyplot-Tier: admin, set in one place of the BFF (agent_context) from the identity its admin gate verified, never from the client'ssource: fanout. The agents service trusts it only behindcheck_caller. A missing or other value ispublicand never a 422, so the API and the agents service deploy in either order./v1/statusreports the caller's ownuser_max_runsandtier, and the panel reads "at most 5 at a time".run_limitin the panel is no failure (nextFanOutinfanoutRules.ts): the library goes back to waiting and the panel shrinks to the runs the server took. A freed place or a 15 s retry tries one library again, and a library gives up only after 30 minutes, which its card says. A single chat'srun_limitmessage is unchanged.pipeline_run:lane,queue_wait_ms,queue_position,user_runs_today(1, 2_5, 6_10, 11_20, 21_30, 31_40, 41_plus) anduser_tokens_today(lt25, 25_50, 50_75, 75_90, 90_100, over, against the public budget for every tier). They are closed sets and bounded integers, with no user id, no hash and no key. Limit refusals stay inchat_turn.refusalandchat_turn.error; the admin's land there too (tested). The views SQL is updated, and a test pins every model field to its view column. The views are not applied in BigQuery: that write needs the owner's OK after the merge.busy, andpipeline_resultnames the innermost error class.pipeline_renderlogs the agents-side render slot wait.Live measurement (local stack, real renderer, Claude Haiku 5.5 in
eu, final values)One 15-library fan-out of
scatter-basicas admin, by scripted HTTP through the local BFF, 5 at a time from/status:okokcapacity/run_limiterrors200for Haikueuin the window)queue_wait_ms)slot_wait_s)The 10 s gap costs about 40 s at the start of a fan-out, and it spreads the renders: the p95 render slot wait drops from 24 s to 7.7 s. All 15
pipeline_runlines carriedlane=admin, and none contained an id.Decisions taken in the brief's scope (decide and document)
start_gap_s = 60 / AGENT_RUNS_PER_MINUTE), not just a sliding window of 6. The window alone would let a fan-out start five runs together, and their renders would meet in the one render slot. One setting stays the knob, and the capacity formula gives 60.pipeline_run, becauseuser_runs_todayis a pipeline-run ordinal and the queue entry is the run's. Anot_readyrun carrieslaneonly.capacity, and the page'scapacitytext now names that cause.Open points
anyplot-apiconcurrency (40, one instance). Every chat stream passes through the BFF. At the queue's new capacity, a full queue could hold about 65 BFF streams and crowd out the website's API. Today the admin panel opens at most 5 per window. Before the public launch, raise the API's concurrency or route the chat through its own service. This touches the site's infrastructure, so I left it out of this PR.Plan
N/A (owner-approved plan of 2026-11-01 and the owner's follow-ups of the same day).
Test plan
ruff check,ruff format --check,mypy api core agentspytest tests/unit tests/integration(25,900+ passed):TestFanOut: 15 sessions of one admin at the new values all start, none refused, also beside three public users;run_limitlands inchat_turn, and the view-column pin;busyversuserror, the gates in a worker thread, and the retries.yarn lint,fm:check,type-check,test(1,362 passed;nextFanOut, the three-panel simulation, and a component test where a refused library waits and runs at the next freed place),build.catchonplotTurn) is fixed.https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8