acpmux: a session list too large for one message goes in pages
HOLD: stacked on #28161 (its commits show here until it lands); lands after it, then the client PRs (#28272 for web and the mux host, #28273 for the Rust clients) land after this.
_acpmux/sessions and _acpmux/watch with no limit answered the whole list in one reply, and so did a filtered _acpmux/sessions (status, excludeStatus, peer, localOnly) without limit. On a daemon that mirrors a fleet, that list passes the 15 MiB message limit (rpc::MAX_MESSAGE_BYTES). One daemon's 5241 rows came to about 17 MB with tools on 2026-10-30, the same list that broke peer handshakes (#28105). Such a list was refused as message.too_large: acpmux ls --tools, a non-link watch {withTools: true} and every client asking for the whole list got an error instead of the sessions.
The rule: page, don't refuse, for every fleet-growing reply
MAX_MESSAGE_BYTES stays a hard limit on one message. That protects every reader from a single unbounded frame. What changes is how a reply that grows with the fleet meets it: it goes in pages, each under the limit with a cursor to the rest, instead of being refused whole. Raising the limit would only move the cliff, since the list grows with peers Ć chats Ć tools. This PR applies the rule to _acpmux/sessions and _acpmux/watch; #28161 applied it to ACP's session/list. Replies already bounded (history pages, _acpmux/chats with MAX_LIMIT, peer snapshots, mailbox) keep their bounds. _acpmux/wait with no sessions is the one fleet-growing reply left (about 3,000 running sessions fleet-wide to reach the limit), and is a follow-up.
What changed
hub/list_guard.rsSnapshot::bounded: a page of the shared list text (with or without tools) cut by bytes withsession_pages::byte_page, at mostLIST_PAGE_BYTES(a megabyte under the limit). It is spliced from the snapshot's serialized rows, filters applied bysession_list_replyon the keys, and carries anextCursorwhen rows are left. The list with tools is built once per snapshot (full_rows) and shared by its pages. A single row larger than a page goes as a stub{sessionId, updatedAt, peer, status, omitted {bytes, reason: "too_large"}}, as history pages and #28161'ssession/listdo.server/list_reply.rssessions(): with nolimitand noorderBy, the whole,withToolsand filtered lists go throughboundedwhen they don't fit. A list that fits is the same cached reply as before, with no cursor.within_limitcuts a peer's masked page again at half the budget if masking lengthened it (shared withsession/list).client.rsClient::whole_list, used by every caller in this crate that read the whole list (acpmux lsand pending, MCPls,orchestrate,place, session move, the TUI and its watch): it followsnextCursorwith_acpmux/sessions(the same params, withoutenabled) and joins the pages. With alimitit is one request, as before.docs/acpmux-schema.jsondocumentsnextCursoron a reply withoutlimit.
A client that ignores nextCursor reads the first 14 MiB of a list it could not read at all before. Against an older daemon, whole_list gets one reply with no cursor, as before.
Performance
Synthetic fleet: 25 peers Ć 210 mirrored chats = 5250 rows, each with a 3 KB preview and a 3.3 KB tool list (the incident's row sizes).
| metric | before | after | delta | harness or run link |
|---|---|---|---|---|
_acpmux/sessions (no limit) | 18,153,992 B in one reply, refused (message.too_large) | 18,154,078 B over 2 pages, each ⤠14 MiB | list readable; +86 B (one cursor) | session_list_pages_tests (red, green) |
_acpmux/sessions {withTools} | refused | 35,694,414 B over 3 pages | list readable | same |
_acpmux/sessions {excludeStatus} | refused | 18,154,078 B over 2 pages | list readable | same |
_acpmux/watch | refused | 18,154,078 B over 2 pages | list readable | same |
| rows read | 0 | 5250 in each case, none repeated | +5250 | same |
Test
cargo test -p acpmux --lib -- session_list_pages_tests list_guard acp_list_tests:
-
a_fleet_sized_session_list_goes_in_pages_under_the_message_limit: the 5250-row state above, read through_acpmux/sessions,withTools, a filter and_acpmux/watch, followingnextCursor. Every row comes once, with tools exactly when asked, and no reply line is overMAX_MESSAGE_BYTES. -
a_session_list_that_fits_is_one_reply_with_no_cursor: 40 rows, one reply, no cursor, for each kind. -
a_row_larger_than_its_page_goes_as_a_stub_with_its_keys: a 5 KB row in a 1 KB page goes as a stub with its id and peer. -
Red on the test commit ed843fe3 (run 41028528918):
_acpmux/sessions: message too large to send: 18153992 bytes, the limit is 15728640 -
Green on the head b6fa022a (run 41040638704): these tests pass. In
cargo test -p acpmuxevery target passes excepthermetic_daemon_guard, which fails the same way on base (tests/web_image_attachments.rs lacks the hermetic helper; #28150 fixes it). The job's exit 1 is forced so the log prints.
Observed / Not run
Observed: the red and green runs above, on hosted Linux through cmux-ci test. An earlier full run at 5dba641a also failed agent_digest::agent_messages_a_failed_turn_held_go_in_after_the_next_completed_turn, which failed the same way on #28172 (an unrelated PR), and auto_start_a_caller_session_named_in_params_or_with_a_wrong_token_counts_for_nothing. Neither failed on the head, so both are flakes.
Read, not run: the app's Swift clients (AgentTurnStateFeed, AcpmuxPermissionFeedBridge, AcpmuxChiefSessionRouter, AcpmuxDecisionContexts), the iOS feed and the Chief read the whole list too. They work unchanged against this daemon until a list passes 14 MiB, when they read the first page only. Their cursor-following changes are follow-up PRs (macOS and iOS each through a fleet Swift compile; the Chief with its owner's review).
Changelog
- Fixed: listing sessions on a machine that mirrors many peers' chats answers in pages instead of failing as too large.
Agent session: agent session
š¤ Generated with Claude Code