acpmux: a session list too large for one message goes in pages

Draft
TE
6d ago
+892-71

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.rs Snapshot::bounded: a page of the shared list text (with or without tools) cut by bytes with session_pages::byte_page, at most LIST_PAGE_BYTES (a megabyte under the limit). It is spliced from the snapshot's serialized rows, filters applied by session_list_reply on the keys, and carries a nextCursor when 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's session/list do.
  • server/list_reply.rs sessions(): with no limit and no orderBy, the whole, withTools and filtered lists go through bounded when they don't fit. A list that fits is the same cached reply as before, with no cursor. within_limit cuts a peer's masked page again at half the budget if masking lengthened it (shared with session/list).
  • client.rs Client::whole_list, used by every caller in this crate that read the whole list (acpmux ls and pending, MCP ls, orchestrate, place, session move, the TUI and its watch): it follows nextCursor with _acpmux/sessions (the same params, without enabled) and joins the pages. With a limit it is one request, as before.
  • docs/acpmux-schema.json documents nextCursor on a reply without limit.

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).

metricbeforeafterdeltaharness 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 MiBlist readable; +86 B (one cursor)session_list_pages_tests (red, green)
_acpmux/sessions {withTools}refused35,694,414 B over 3 pageslist readablesame
_acpmux/sessions {excludeStatus}refused18,154,078 B over 2 pageslist readablesame
_acpmux/watchrefused18,154,078 B over 2 pageslist readablesame
rows read05250 in each case, none repeated+5250same

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, following nextCursor. Every row comes once, with tools exactly when asked, and no reply line is over MAX_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 acpmux every target passes except hermetic_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