acpmux: every session list page and session info fits one message (ls never hits message.too_large)

TE
5d ago
+496-58

Summary

On a fleet host, plain acpmux ls fails: acpmux: runtime: message too large to send: 16927852 bytes, the limit is 15728640. No one can list that host's sessions, and the census and stall reconciler can't see it either. The CLI only retries with smaller pages, so the daemon has to send every list reply whole and under the cap.

Where the daemon still built a reply too large to send:

  • limit pages were cut by row count only. acpmux ls reads pages of 1000 rows with usage (#28373). The census's orderBy pages and every withTools page are cut the same way. A page of large rows came to 37 MB in the test below. The byte-bounded paging from #28182 covered only replies with no limit.
  • One large row went whole into a limit page. On the byte pages it became an id-only stub. Every field was lost, its cwd and status included.
  • _acpmux/info sent a detail of any size, for example a chat whose title is a whole pasted prompt.

The fix fits every reply as a whole and never refuses one:

  • New hub/reply_fit.rs holds the shared rule and bound. shrink_largest replaces the largest parts first until the value fits. REPLY_BYTES is 1 MiB under rpc::MAX_MESSAGE_BYTES. fit_object trims a row's largest fields: a string keeps a 1 KiB preview, any other value is dropped. It never trims the fields a row is found and opened by (sessionId, id, qualifiedSessionId, sessionReceipt, peer, status, harness, cwd, updatedAt, name; the same set #28754's attach reply keeps), and it marks the row omitted: {bytes, reason: "too_large", fields: [...]}. LIST_PAGE_BYTES is now REPLY_BYTES.
  • Snapshot::page (the limit pages and census pages) holds at most limit rows and at most REPLY_BYTES bytes. A page cut short by size returns the nextCursor of its last row (k…, or s:<id> for a census), and clients already follow it. withTools pages use the same path. They no longer parse a copy of the whole list.
  • A row too large for a page by itself is trimmed, on the byte pages too. Only a row whose kept fields alone are that large still goes as the id stub.
  • _acpmux/info runs its detail through fit_reply_object.
  • A trimmed row still reaches a peer masked (peer_text.rs). lastSeq is a kept field, since masking recognizes a summary by sessionId, harness and lastSeq. A preview is masked over 4 KiB past its cut and only then cut, the way views.rs masked_cut treats titles, so a secret split by the cut is never left as a head.

#28754 adds the same kind of fit for the chat-open reply (_acpmux/attach). The two are built on one rule: paging::fit_reply there is shrink_largest over /events/N/msg and /turnOpener/msg. Whichever PR lands second switches to the shared helper and the single bound.

Related: #28182, #28373, #28161, #28105 (the earlier paging work).

Testing

server/list_fit_tests.rs (new) builds a hub with 1,000 peer rows of 20 KB plus one 16 MiB row, and a local chat with a 16 MiB title. The list as a whole is past the cap, and so is one row alone. Through a real server connection:

  • _acpmux/sessions with {limit: 1000, usage: true}, {limit: 1000, withTools: true}, {} and a census (orderBy): following nextCursor to the end lists every id once, and every reply line is under MAX_MESSAGE_BYTES. The large row keeps its id, peer, status and cwd, its planStep becomes a preview, and it is marked omitted.fields = ["planStep"]. Small rows are unchanged.
  • _acpmux/info on the 16 MiB-title chat answers under the cap, with a title preview and omitted.fields naming title.
  • Over a peer connection (masked), an oversized peer row whose lastPrompt holds one token near its start and one split at the 1 KiB cut keeps lastSeq, and neither token's start appears in the preview (a_trimmed_row_still_goes_to_a_peer_masked). With reply_fit.rs as of 468e477 this test fails: the split token leaked as ghp_CutkQ9mX7pL2vB8n….

Command: cargo test -p acpmux --lib -- list_fit (in cmux-tui/).

  • Base (test commit 2b4c96cc8f6 alone): both fail. The first page fails with {"limit":1000,"usage":true} page 0: message too large to send: 37190897 bytes, and info fails with message too large to send: 16779433 bytes.
  • Head: all pass. With -- list_fit reply_fit list_guard list_reply acp_list session_pages ls_pages native_history peer_text: 59 passed, 0 failed. cargo clippy -p acpmux --lib --tests -- -D warnings is clean.

Observed / Not run

  • Observed: the red/green above, run locally on a Linux lane VM (cargo test -p acpmux --lib with a filter). Panic ratchet (rust_panic_ratchet.py, -p acpmux): arithmetic_side_effects and cast_possible_truncation report above baseline, but none of the hits in the changed files are on lines this PR adds (they are existing base code).
  • Not run: the full acpmux suite here (CI runs it), and a live acpmux ls on the failing host. That host runs an acpmux older than #28373 (its error is the raw daemon message, which the paged CLI would have caught and retried smaller), so it needs an update that includes this change first.

Changelog

Fixed: acpmux ls and acpmux session info work on a host whose session list, or one session, is larger than one message; an oversized field shows as a preview and is marked omitted.

Proof

No UI: daemon reply sizes only. The tests above show it.

metricbeforeafterdeltaharness
first _acpmux/sessions {limit:1000, usage} reply, 1,001 rows of 20 KB plus one 16 MiB rowrefused (37,190,897 bytes)≤ 14 MiB page with nextCursorlisted instead of refusedserver/list_fit_tests.rs
_acpmux/info of a chat with a 16 MiB titlerefused (16,779,433 bytes)under 15 MiB, title as a 1 KiB previewanswered instead of refusedserver/list_fit_tests.rs

Agent session: agent session

🤖 Generated with Claude Code