acpmux: other sessions' messages steered into one step go in as one input

Merged
TE
8d ago
+747-52

Leo's ask, in his words: "i also feel like there should be better batching for messages from other sessions so you can get to things faster/more efficiently"

What was wrong

Another session's message sent into a running Claude Code turn is a steer: acpmux writes it to Claude Code's stdin and Claude Code reads it when its current step ends. Each steer was its own line, so ten agents' messages during one long step were ten separate inputs. After a Send now (an interrupt), Claude Code ran each waiting line as one more turn: ten messages were ten turns.

What changed

The daemon now batches them at the step boundary (claude_stdio/steer_batch.rs):

  • The hub marks another session's plain steer with its sender (_meta.batch.from, hub/steer_end.rs).
  • When such a message arrives and the newest line still waiting in Claude Code's queue is other sessions' messages, acpmux asks Claude Code to drop that line (cancel_async_message, the same request the X on a waiting steer uses). It then writes one line with all of them in its place.
  • That line starts with "N messages from M sessions arrived during this step. Each follows as it was sent, oldest first." Then each message follows under [message i of N] from <sender>, with its blocks exactly as sent. Nothing is dropped, cut or summarized.
  • Only one line is dropped and written again at a time. Messages that arrive while that drop is on its way wait for its answer and go into the line written after it, so the lines reach Claude Code in the order their messages came. That wait is one control round trip; no timer is added.
  • If Claude Code already read the line (cancelled: false), that line stands and the new messages go in as the next line. If Claude Code refuses the drop (an older Claude Code), each line stands and acpmux stops asking.

What stays the same:

  • Each send keeps its own session/prompt, promptId, user_message record, acceptance and delivery id, so acpmux send --status still reports each one delivered. When the batched line's echo comes back, every message in it is answered steered.
  • The person's own steers, a steer read now (steerNow), family messages, slash commands and messages with uploaded files are written at once, as before. They are never held.
  • "Read after this turn" messages still wait in the queue and still go in together at the turn boundary (hub/digest.rs, unchanged). Each kind goes in at its own boundary.
  • A withdraw of one message inside a batched line, or of one held for a line being written again, takes nothing back: it answers withdrawn: false, the same answer as for a message Claude Code already read. The person's steers, which are what the X is for, are never batched. This is why a dropped line is always written again with at least its own messages, so Claude Code always has a line to echo, and a turn whose result came while the line waited still ends.
  • If a steer reaches the translator after its turn ended, it answers STEER_NO_TURN and the hub queues it.
  • If a turn ends while a message is held, that message answers STEER_NO_TURN, so the hub queues it for the next turn. If an answer is lost, the held messages fail the same way the written ones do.
  • Codex chats are unchanged: Codex folds steers into its pending input itself.

The UI card that shows several waiting messages as one card is a separate PR, and it renders correctly with or without this one.

Tests

tests/hub_integration/steer_now.rs ten_agent_messages_during_a_long_step_arrive_as_one_input: ten fromAgent sends from three senders go into fake Claude Code's long step, then Send now. The agent reads one input holding all ten, each with its text and sender, in order. Each send is answered steered and has its own record.

claude_stdio/steer_batch_tests.rs stands in for Claude Code's command queue and covers:

  • ten messages in one line;
  • the person's steer and a steer read now are not held;
  • a line read first stands;
  • a withdraw of a batched or held message takes nothing back;
  • the turn's result arrives before the drop is answered, and the turn still ends;
  • only one line is rewritten at a time, and order is kept;
  • a steer that arrives after its turn ended;
  • messages past the 4 MiB line cap split into lines of whole messages, in order;
  • a refused drop;
  • a lost answer.

Before the fix (test-only commit c58de5ddd15, run 40639281338):

steer_now::ten_agent_messages_during_a_long_step_arrive_as_one_input --- FAILED
assertion `left == right` failed: one input, not one per message: [
    "read: MSG-01 from the lane",
    ...
    "read: MSG-10 from the lane",
]
  left: 10
 right: 1
claude_stdio::steer_batch_tests: 2 passed; 4 failed

After, at 0d85dc1ff78 (run 40656508265): cargo test -p acpmux --lib claude_stdio 111 passed (all 11 steer_batch_tests included); --lib hub:: 489 passed; --test hub_integration steer 24 passed (the ten-message test included); --test send_steer 5 passed; cargo fmt --check and cargo clippy --all-targets -D warnings clean.

Review

Codex gpt-6-astra (high, fast) through the review router, reading the mirror only.

  • Round 1 (05bc1c69c01) said BLOCK with three findings: a steer racing the turn's end, two rewrites in flight reordering lines, and a withdraw registered after fail_pending drained. All three are fixed in e787bbaf60d.
  • Round 2 said BLOCK with two findings. The hang, where the result arrives before the drop answer and the line is emptied by withdraws, is fixed in ab0fa2f05e9. The lock-order finding is answered: steer_withdrawn's pending guard is a statement temporary, never held across steers.
  • Round 3 (ab0fa2f05e9) agreed the lock-order point is not a defect and the hang is fixed. It said BLOCK on one remaining finding: messages that each fit could merge into a line past the agent host's 64 MiB frame limit. Fixed in 98268f77e1b: a batched line holds at most 4 MiB of whole messages, and the next message starts a new line, whole and in order (messages_past_the_cap_split_into_lines_of_whole_messages).
  • Round 4 (98268f77e1b) asked that a withdraw of one batched message rewrite the rest. That came from a stale line in my prompt, not from the brief. The design answers withdrawn: false instead, and the app's X only shows on the person's own steers, which are never batched.
  • Round 5 at 0d85dc1ff78 (doc and test trims only): SHIP, no findings.

CI: release-notary and Guard status fail the same way on base (feat-cmux-next 54c7c86e7a, bfb71a2514, c30fa03db4 and others). cmux-next fleet Release compile (the Swift app compile; this PR changes no Swift) times out waiting on the compile controller, and it fails on base too (8 of the last 50 feat-cmux-next commits, the rest still running). The Rust compile, clippy on the changed packages, the fast tier and the seven-language conformance are green at the head. The fast-tier and SDK-conformance reds at 05bc1c69c01 were a ghostty dependency tarball download failure (unable to unpack tarball ... ReadFailed), and they are green at e787bbaf60d.

Performance

metricbeforeafterdeltaharness or run link
inputs the agent reads for 10 other sessions' messages sent during one step101-90%hub_integration steer_now::ten_agent_messages_during_a_long_step_arrive_as_one_input, before / after
turns Claude Code runs for them after a Send now101-90%same test (fake Claude Code runs each waiting line as one turn)

Risk

The risk is in Claude Code's steer path only. A batch costs one extra control round trip while the newest waiting line is dropped and written again. In that window a step boundary could pass, and then the batch is read at the next boundary. No new timer, observer or loop is added.

No capture: nothing visible changes in this PR. The transcript still records each message as its own row, and the batched card is the UI PR.

Agent session: agent session
šŸ¤– Generated with Claude Code