acpmux: ACP session/list goes in pages when the whole list is too large to send

Merged
TE
6d ago
+448-28

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 sessionId and peer, 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 (FullRows in hub/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 and cwd (each at most 4 KiB) and no title, the way a record too large for a history page goes. Rows keep their tools.
  • _acpmux/sessions cursors 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).

metricbeforeafterdeltaharness
session/list replies to read the whole list1, refused (message.too_large)2 pages+1 reply, list readableserver/acp_list_tests.rs a_fleet_sized_acp_list_goes_in_pages_under_the_message_limit (red, green)
rows read05250+5250same
bytes of the list20,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 ms146-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 time53 ms30 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; following nextCursor reads every row once, with tools, and no reply line is over MAX_MESSAGE_BYTES.

  • an_acp_list_that_fits_is_one_reply_with_no_cursor: 40 rows, one reply, no nextCursor.

  • 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_cursors and the extended streamed_rows_preserve_all_fields_and_stable_ties (hub/list_guard.rs): the _acpmux/sessions cursor 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 MB cwd and 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 warnings is clean; in cargo test -p acpmux everything 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_home and held_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/watch without limit answer whole: with tools that is the same 17 MB (acpmux ls --tools, a non-link watch {withTools: true}); without tools about 2.3 KB per row, so about 6,900 rows at the incident's mix. A filtered _acpmux/sessions without limit (status: closed, excludeStatus) also answers whole. Paging these needs their clients to follow nextCursor; a follow-up.
  • _acpmux/wait with no sessions waits 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_LIMIT 5000 Ɨ ~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/list 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