acpmux: ACP session/list goes in pages when the whole list is too large to send
ACP's session/list answered every session, local and mirrored, with tools, in one reply, and took no parameters. On a daemon that mirrors a fleet that reply passes the 15 MiB message limit: one daemon's 5241 rows were about 17 MB with tools on 2026-10-30 (the same list that broke peer handshakes, #28105), so session/list there came back as message.too_large, with no way to read less.
session/list now reads ACP's own cursor and answers with nextCursor (both in ACP's session/list schema). A list that fits in 14 MiB (a megabyte under the limit, for the frame and masking) is one reply exactly as before, newest first, cached per list build, with no nextCursor. A larger one goes in pages of at most 14 MiB, cut by bytes (hub/session_pages.rs byte_page, at least one row per page).
- Order and cursor: pages go in the order of a 128-bit hash of each row's
sessionIdandpeer, not newest first, and the cursor is that hash (h+ 32 hex, 33 bytes whatever the ids). A session that updates or leaves while a client pages moves no other row, so none is skipped or repeated; a row listed twice (one id, one peer) comes once; a page never ends between rows of one hash. - Cost: a page is cut from the list snapshot's own row spans (
FullRowsinhub/list_guard.rs), so it parses and serializes only its own rows. A list whose rows alone are over the budget does not build the whole reply only to drop it. - Masking: a peer reads the page masked, which can lengthen it: such a page is cut again at half the budget. Raw pages take the same final size check.
- Oversized row: a single row larger than a page goes as a stub with
_meta.acpmux.omitted {bytes, reason: "too_large"}, keeping only its id andcwd(each at most 4 KiB) and no title, the way a record too large for a history page goes. Rows keep their tools. _acpmux/sessionscursors now hash the peer with the id, and ties sort by peer (local first) in both sorts (sort_session_summaries,Rows::finish), so a chat here and its copy on a peer each have their own cursor. Cursors of this daemon's own rows are unchanged; an old cursor for a peer's row falls back to the stamp and may repeat, never skip.
Performance
Synthetic fleet: 25 peers Ć 210 mirrored chats = 5250 rows, each with a 3.3 KB tool description (the incident's mean row size with tools).
| metric | before | after | delta | harness |
|---|---|---|---|---|
session/list replies to read the whole list | 1, refused (message.too_large) | 2 pages | +1 reply, list readable | server/acp_list_tests.rs a_fleet_sized_acp_list_goes_in_pages_under_the_message_limit (red, green) |
| rows read | 0 | 5250 | +5250 | same |
| bytes of the list | 20,559,292 in one reply, refused (the client got a ~200 B error) | 20,559,390 over 2 pages, each ⤠14 MiB | +98 B (one cursor) | same (PERF acp_list rows=5250 bytes=20559390 pages=2) |
page 1 time (the first session/list of an oversized list) | 278-284 ms | 146-149 ms | -48% | run 41064470951: acp_list_tests::a_fleet_sized_acp_list_goes_in_pages_under_the_message_limit, release, 3 runs each, the same runner and test file; before = this PR at 19689e46 (whole list parsed per page), after = 02632c21 |
| page 2 time | 53 ms | 30 ms | -43% | same run |
Test
cargo test -p acpmux --lib acp_list_tests:
-
a_fleet_sized_acp_list_goes_in_pages_under_the_message_limit: the 5250-row state above; followingnextCursorreads every row once, with tools, and no reply line is overMAX_MESSAGE_BYTES. -
an_acp_list_that_fits_is_one_reply_with_no_cursor: 40 rows, one reply, nonextCursor. -
byte_pages_read_each_row_once(hub/session_pages.rs): a chat here, its copies on two peers and a row listed twice, at five page budgets: every row comes once (red without the dedupe). -
byte_pages_hold_their_place_while_sessions_update_and_leave: one row a page; between pages an unread row becomes the newest, then a read and an unread row leave: every remaining row comes once. -
a_byte_page_cursor_stays_small_for_a_huge_id: rows with 1 MiB ids; the cursor is 33 bytes (red with the ids in the cursor). -
copies_of_a_chat_on_peers_page_by_their_own_cursorsand the extendedstreamed_rows_preserve_all_fields_and_stable_ties(hub/list_guard.rs): the_acpmux/sessionscursor and both sorts tell a chat's peer copies apart (each red without its change). -
an_acp_row_larger_than_its_page_goes_as_a_stub(server/list_reply.rs): a row over its budget, and one with a 1 MBcwdand title, go as stubs under 400 B with no title. -
Red on the test commit 909561f7 (run 41026359386):
page 0: message too large to send: 20559292 bytes, the limit is 15728640 -
Green on the head 02632c21 (run 41060184119, tested tree b7e0a5a9 = the head's tree): the tests above pass;
cargo clippy -p acpmux --all-targets -- -D warningsis clean; incargo test -p acpmuxeverything passes except three:hermetic_daemon_guard(every_test_binary_runs_in_the_hermetic_environment), which fails the same way on base before #28000 and at the tip 7f97b848; and two that passed 5 of 5 when rerun alone at the same tree (run 41062160697), in code this PR does not touch:daemon::state_file_tests::managed_unit_start_avoids_a_symlinked_default_homeandheld_queue::agent_messages_joined_to_a_send_whose_agent_cannot_start_are_not_held(load flakes). The job's exit 1 is forced so the log prints.
Review
Independent Codex (gpt-6.1-sol, high), merge-base diff: SHIP at 02632c21, no findings, after four BLOCKs fixed in this PR: rows that share a cursor straddling a page edge; a chat's peer copies sharing a cursor; newest-first pages skipping a row that updates between pages; an unbounded cursor and whole-list work per page.
Observed / Not run
Observed: the red and green runs above, on hosted Linux through cmux-ci test, and the release timings. Read, not run: ACP clients in the wild; an ACP client that ignores nextCursor now reads the first 14 MiB of a list it could not read at all before.
What else grows with the fleet (audit of the other list replies)
_acpmux/sessions/_acpmux/watchwithoutlimitanswer whole: with tools that is the same 17 MB (acpmux ls --tools, a non-linkwatch {withTools: true}); without tools about 2.3 KB per row, so about 6,900 rows at the incident's mix. A filtered_acpmux/sessionswithoutlimit(status: closed,excludeStatus) also answers whole. Paging these needs their clients to follownextCursor; a follow-up._acpmux/waitwith nosessionswaits on every running row and answers full rows twice; about 3,000 running sessions fleet-wide to reach the limit.- Bounded already:
_acpmux/peers,_acpmux/peer_status(problems capped),_acpmux/models(about 1 MB at 26 daemons),_acpmux/chats(MAX_LIMIT5000 Ć ~700 B), mailbox, team inbox, history pages (MAX_PAGE_BYTES), peer snapshots, workflows, forum, bg tasks, composers. No notification carries a fleet-wide list.
Changelog
- Fixed: ACP
session/liston a machine that mirrors many peers' chats answers in pages instead of failing as too large.
Agent session: agent session
š¤ Generated with Claude Code