acpmux: session inbox NAME --digest drains agent messages into one prompt

Merged
TE
6d ago
+2215-36

Leo, 2026-10-29: "What are we gonna do with these 200 fucking messages? You never end a fucking turn. As soon as it comes in, it fucking, like, occupies you for another fucking turn." and "you can't you just deal with this? Like ACPmux can't be this shit. How do swarms work?"

What

A coordinator session that 30+ lanes report to had 285 agent messages queued, and each one it read cost it a turn. This adds a one-shot drain:

acpmux session inbox NAME --digest (_acpmux/inbox_digest {sessionId, dryRun?}, hub/inbox_digest.rs)

  • Takes the other agents' text messages waiting in NAME's queue (the ones digest.rs counts as another agent's) out of it and puts ONE digest prompt in their place.
  • The person's own prompts are never touched: they stay where they are, ahead of the digest (held ones too).
  • Each original leaves the queue with a dequeued record carrying digestPromptId and digestTurnId. Its full text stays in the log under its own promptId (its queued record), and its waiting session/prompt answers with the digest's answer (joinedPromptId: the digest).
  • When NAME's turn is running, below its turn cap (#27520), and its agent steers, the digest goes in as ONE steer. Otherwise it waits at the head of the agent messages in the queue, behind the person's prompts, and runs as its own turn: no later agent message joins it (take_joined skips digests both ways).
  • --dry-run (and inbox NAME without --digest) prints the digest and changes nothing.
  • Allowed only over the local unix socket, from the person or from NAME's own agent (its caller token). Refused from Web, LocalApp and Peer (inbox_digest.origin_refused), and from another session's caller token (inbox_digest.other_agent). Never forwarded to a peer.
  • Left in the queue: messages with anything but text (images, uploads, hidden context), slash commands, sub-session family messages, ones sent or steering now, Web ones, and earlier digests.

The renderer (shared with P2)

render(digest_id, &[Part {prompt_id, from, from_name, class, received_at, text}]) -> Rendered {text, provenance}:

  1. Blocks and questions first.
  2. Results grouped by sender (senders in order of their oldest result), oldest first.
  3. FYIs as one line each (first line, up to 160 characters).
  4. Exact-duplicate texts from one sender fold into the newest copy, marked (×N). A sender is its address (from), not its display name.

Each part is headed by its sender and promptId. Blocks, questions and results keep their whole text up to DIGEST_MAX_BYTES in total; past that a part shows its first 400 characters and [full text: promptId …].

The digest's provenance is hub-set, never taken from a client: {kind: "inbox_digest", digestId, parts: [{promptId, from, fromName, class, receivedAt, bytes, duplicateOf?}], counts: {block, question, result, fyi}}.

Class

A message's class comes from its queue entry (PromptRun.class, set by send --class, #27512), and a message with none counts as a result. a_digest_puts_blocks_and_questions_first_and_fyis_last_by_the_class_sent checks the order end to end.

Also

  • Restart safety: the digest's queued record is logged before the originals' dequeued records and names every original (provenance.parts). A resend of an original after a restart answers from the digest's turn once that turn started (prompt_ledger.rs).
  • A prompt a restart restored whose task has not claimed it yet stays queued, as take_joined leaves it. The drain takes no turn lock, so a prompt whose turn came just as the drain took it answers from the digest (queue_slot); if that digest's turn fails and puts it back, it waits again.
  • A joined answer's turnId is the turn it went into: a steered digest's originals name the turn that ran then.
  • A digest refused or failed puts the messages it took back before its answer (requeue_joined). Its turn takes them over as it starts (take_joined), so a failed turn puts them back exactly as it puts back messages that joined it: held only after its prompt reached the agent. They are logged queued with requeued: true, so their senders keep waiting and a restart restores them. A digest refused before its turn puts them back as they were (held when a failed turn held them); one whose turn failed puts them back held.
  • A restart before a digest's turn started (its turn_started, or its steer's user_message; still queued, refused, stopped between its prompt and its turn, or a steer cut before it was written) drops the digest and gives its messages back in its place, each once (queue_waiting.rs Digests). After the digest's turn started, its turn is theirs and none runs again on its own. The digest's queued record names its messages and holds the queue's new order, so this holds whichever later record a stop cut off. A resend follows the digest only once its turn started. A digest the person edited is their prompt and stays; one they withdrew answered its messages cancelled and a resend answers withdrawn: a restart gives back none of their messages. Messages given back keep their order (a partial requeue included), and drained again they follow the newest digest.
  • A part's text is a cmux agent message's own words, without its envelope.
  • Only a proven caller token counts as the chat's own agent. A token this daemon run did not issue is refused with inbox_digest.caller_not_proven. A unix connection that names no session is trusted as the socket already trusts it for every queue change: it may queue_remove, queue_edit, cancel or kill any chat. The acpmux client names its session by itself when it runs in one.
  • prompt_line.rs: a digest's sidebar line reads as its senders plus "inbox digest of N messages".
  • A digest whose turn ends before its steer lands goes back to the head of the agent messages without stopping any turn (steer_queued).
  • The protocol IR (spec/acpmux-protocol.json) and the generated web method list gain _acpmux/inbox_digest. docs/acpmux-schema.json documents the method and the new dequeued fields.

No capture: no visible change. The only webviews file is the generated protocol method list (methods.ts, regenerated from the registry), and no UI calls the new method.

Size

About 2,090 changed lines: roughly 500 for inbox_digest.rs, 1,100 for tests (most of them the restart, refusal and turn-cap cases five review rounds asked for), and about 300 for wiring and restart handling. It isn't split because the drain, its renderer, its restart safety and their tests are one state change on the queue. The renderer alone has no caller, and a drain without its restart handling loses messages.

Tests (red first)

  • Red, tests only (742f62d): run 40937377967, all 5 of its inbox_digest integration tests fail (no such method). The class-order test came after #27512 landed.
  • Green at e09437e2a91: run 40981198206. cargo fmt --check passes; lib 1981/1981; hub_integration 572 pass, including all 17 inbox_digest tests (the 2 failing are the base reds below); clippy -D warnings is clean. Final head 28e2e3d8d36: run 40982413576, the same. Earlier heads were green the same way: run 40967913993 at a588e9ffdb1, run 40959842140 at ef9f349f935, run 40956862813 at a0b96e01b1a, run 40954965470 at 245fe71aa07, run 40947068400 at 0e1f29b9591 and run 40939715498 at 085901bbc38.
  • Review round 1 (red then green): the tests for its findings failed in run 40953443333 at 81f95970d21. The forged caller drained the chat. A stop that cut the dequeued records restored the originals. A resend answered interrupted. A steered digest's answers named the digest's queue turn instead of the running one.
  • Review round 2 (red then green): its tests failed against a0b96e01b1a in run 40960683158. A steered digest the stop cut before its steer was written was lost. A cut queue_order put a slash command the digest left ahead of it. A resend whose dequeued record was cut answered interrupted. The FYI-of-an-envelope unit test failed in run 40958523333.
  • A sibling review found a message-loss bug here. A digest refused before its turn (session.folder_missing) never put back the messages it took. Its test failed in run 40959738138 at d4152dcc77c ("no requeued queued record").
  • Review round 3 (red then green): its tests failed at b270265935 in run 40967931881. A digest that never ran stayed in the restored queue, which a refusal of its resumed run would lose. A steered digest cut before it was written did not give its messages back. A stop between a refusal and the requeued records lost them.
  • Review round 4 (red then green): its tests failed at 8b738a004ab/b33c83ec086 (tests only) in run 40975687732, and pass with the fix in run 40975734444. A stop between a digest's prompt and its turn kept the digest without its messages. A person's edit of a waiting digest was lost at a restart. A withdrawn digest's messages came back. A partial requeue reversed their order. Messages drained twice did not follow their newest digest.
  • With #27520's turn cap on base: a digest no longer steers into a turn past its cap; it waits for the next turn, as the messages it took would have. Its test failed at 8e073dc6f8e (tests only) in run 40976789992 ("steer", not "queued").
  • Review round 5 (red then green): its tests failed at 5c9af96ed14 (tests only) in runs 40979730098 and 40981169826. A digest whose agent could not start put its messages back held, so they waited out the 10 s retry. A digest drained behind a held prompt of the person's went ahead of it. A digest marked to steer that found the turn past its cap when its task steered it steered in anyway. A sender who changed its display name between two identical messages was not folded.
  • The 2 hub_integration reds are base reds. They fail the same way on base a653ee370c9 in run 40939611174, 3 of 3 times each: auto_resume::retry::stop_before_a_retrys_turn_starts_prevents_it (auto_resume_retry.rs:222, "turn was stopped before this prompt reached the agent") and transcript_events::orchestration_fields_reach_subagent_events_and_session_summary (transcript_events.rs:721, tokens Null != 1234). prompt_supersede::a_prompt_queued_before_a_restart_is_still_replaced is flaky on base too: it failed 1 of 4 runs there.

Integration (tests/hub_integration/inbox_digest.rs): the person's prompts stay ahead and untouched; originals leave with dequeued.digestPromptId while their text stays in the log; one steer into a steering (Claude-style) agent's turn, otherwise queued at the head of the agent messages and run as one turn that nothing joins; grouping and folding; the budget pointer; --dry-run changes nothing; refusal from Web, LocalApp, Peer and another agent's caller token; the chat's own agent may drain. Unit (hub/inbox_digest_tests.rs): class order, folding, budget, the FYI line, class read from the queue entry with none meaning result, and which prompts are digestible.

Risk

Medium. This changes the queue only when someone runs the drain. The rest is two narrow exclusions in fan-in (take_joined never joins a digest), digest placement in enqueue, and steer_queued not interrupting for a digest. The worst case is a digest turn that fails: its originals go back to the queue held, as a failed agent message's turn holds it, and their texts stay in the log under their promptIds. The restart rules (when a digest counts as started, an edited or withdrawn digest) are the subtle part; each has a test that cuts the log where a stop would.

The UI half (grouped rendering, full text on expand) is another lane's and builds against the wire contract above.

Agent session: agent session

🤖 Generated with Claude Code