acpmux ls: read the session list in pages (never one reply), --cursor, titles at most 200 chars

Merged
TE
6d ago
+753-62

Summary

acpmux ls now reads the session list in pages and never asks for it in one reply, so a host's list size no longer matters.

The failure this fixes, as reported from the fleet:

on a fleet host (acpmux c230bb58a), acpmux --json ls fails with {"error":{"code":"runtime","data":{"bytes":15744335,"limit":15728640,"reason":"message.too_large"}}}. ~3,700 sessions with every peer's rows, ~4 KB each, because titles carry whole prompts. --fields, --status, --since and name patterns filter on the client after the daemon sent the whole list; --limit pages in the daemon, but ls takes no cursor.

Pages for every read. ls reads _acpmux/sessions 1000 rows at a time with limit + cursor (cli/run/ls_pages.rs) and prints the same {"sessions":[...]} as before.

  • This works against old daemons too: any daemon since the keyset pages (the reported c230bb58a included) answers limit. A daemon too old for limit sends the whole list, which is read as before.
  • A page too large to send is asked for again at half the rows. If a single row is too large, the error says so and points at session info instead of a bare message.too_large.
  • A row that a later page repeats is kept once. A cursor that comes back twice is an error, never a loop.

Filters run in the daemon before it pages.

  • --status is sent as status (except stuck and waiting, which are not daemon statuses).
  • --live is sent as excludeStatus.
  • --host <this machine> is sent as localOnly.
  • --since stops the read at the first older row; the list is newest first, so no updatedSince param is needed.
  • A peer's --host stays a client-side filter. --host matches peer names in any case, but the daemon's peer filter compares case-sensitively, and peer names may be mixed case. Sending it could silently drop rows.

Scripts can page.

  • ls --cursor C [--limit N] reads one page and prints its nextCursor (null when nothing is left). An empty --cursor '' is the first page.
  • --limit N --json also prints the cursor for the rows after its N. When a client-only filter (pattern, --tag, --pending, --status stuck|waiting) stops in the middle of a page, the cursor is the one for the last row read, built the way the daemon builds it (session_pages::cursor_after). If a daemon's cursor keys differ, the read starts at the first row no newer than that one, so a row from the same millisecond may repeat but none is skipped.
  • A daemon too old for pages ignores cursor. --cursor against it is refused with "too old to page: update acpmux on it" instead of returning the list from the start.

Titles are bounded in census rows. A census row (session_list_summary) shows at most 200 characters of its title, the same cut lastPrompt gets. A harness may title a chat with its whole first prompt. Census rows are both the list and the session_changed rows a watch without tools hears, so the two never show one chat under two titles. The Mac's workspace auto-naming reads them, and a cut in only one would rename a workspace back and forth. session info, attach and --with-tools keep the whole title. I checked the consumers: nothing compares, writes back or keys on a list title. lastTurn.errorText and stuckError are not cut, because leo-workspace's keep_going_reconciler.py, pr_owner.py and lane_failover.py regex-match them from acpmux --json ls.

Prior art and overlap

  • #28182 (open, same area) pages a whole list on the daemon side when it is too large, and switches ls to its Client::whole_list. That fixes new daemons. This PR fixes the CLI against the daemons running now, adds --cursor, and bounds titles.
    • The two touch one line of ls.rs: #28182's whole_list call becomes this PR's paged read. Whoever lands second keeps the paged read; it reads #28182's daemons the same way, since a request with limit still pages by count there.
    • Request 4 of the brief (a whole-list reply over the cap refused with a clear error) is left to #28182. A whole list that is too large goes in pages there instead of being refused, so this PR does not change that daemon code.
  • Related: HQ#5226; the fleet-scale paging PRs #28152 #28172 #28170 #28157 #28161; and #28272 #28273 #28296 #28297 (the clients read a paged list whole).

Performance

metricbeforeafterdeltaharness or run link
largest reply, 3,700 sessions of ~4.4 KB16.2 MB (refused: over 15 MiB)4.4 MB (pages of 1000)-73%, and the list is readls_pages_tests::a_busy_hosts_list_goes_in_pages_under_the_message_limit (fake daemon over the hub's session_list_reply)
requests for that list1 (fails)4+3same test
this lane host's real list, 417 rows1,138,925 B1,137,005 B with titles cut-0.2%acpmux --json ls on a lane VM, titles cut to 200 chars offline

The title cut barely changes rows on this host, because its titles average 58 bytes. I could not read the reporting host's rows from here, so its "titles carry whole prompts" estimate is not measured. Paging fixes the cap however large the rows are.

Testing

  • cmux-ci test -- cargo test -p acpmux --lib -- ls_pages summary_title cli::run::ls:: hub::session_pages hub::views, then cargo clippy -p acpmux --all-targets --locked -- -D warnings. Run on Linux fast checks at each head; results are in the comments.
  • Round 1 at ea8e062cc8f: 12/12 tests passed. Clippy failed on a dead-code field the tests read (Pages.requests); it is fixed in db6c7bcdcdc.
  • An independent Codex review (gpt-6-astra, high) at ea8e062cc8f blocked on three findings. All three are fixed in db6c7bcdcdc: the mid-page cursor, --cursor on an old daemon, and session info keeping the whole title.

Observed / Not run

  • Observed: the field sizes of a real list (acpmux --json ls, 417 rows; the largest fields are lastPrompt/lastTurn/enforcement, about 200 B each). Titles there are already short.
  • Not run: the new ls against the reporting host's daemon. The fake daemon in the tests is the hub's own paging function.

Changelog

  • Fixed: acpmux ls works on hosts whose session list is larger than one message, and --cursor reads it page by page.

Agent session: agent session

šŸ¤– Generated with Claude Code