acpmux: persist session message events
Report-to and session sends currently appear as ordinary prompt records without a durable sender event. This adds _acpmux/message to both session journals and replays it when a chat reopens.
Requested behavior: “Report-to and acpmux send between sessions: today these are ordinary prompt records with no sender. Add an explicit _acpmux/message event {sessionId, sender, text, at, messageId}, so a chat can show the messages coming in and going out.”
For the orchestration UI, the notification and journal payload is:
{
sessionId: string;
sender: { name: string; sessionId: string; host?: string };
text: string;
at: number; // Unix milliseconds
messageId: string;
}
sessionId identifies the journal containing that copy. messageId is the delivery ID returned by acpmux send --status, or the prompt ID for a direct/report send. Direct prompt IDs are scoped to the receiving session: sender copies deduplicate internally by (targetSessionId, messageId). Consumers use journal record identity for replay because outgoing copies for different targets may share a messageId. Peer-origin reads omit sender.host, including event pages and replay. Capability session-messages-v1, acpmux daemon schema, canonical protocol IR, and generated TypeScript bindings document the event.
Queued and held prompts retain their normalized sender and delivery identity. Peer acceptance is scoped by peer, target session, and prompt ID; a provisional duplicate acknowledgement does not settle a journal projection. Accepted prompt records, including digest entries, repair missing local journal copies after restart. Failed local projection writes also enter bounded repair without resending the prompt. Direct peer sends persist a projection-only recovery intent; recovery reads the receiver journal without resending the prompt. It uses the existing bounded retry schedule (about 34 minutes of delays, with 120-second attempt deadlines), stops on settlement or exhausted retries, and releases its per-delivery running guard when the task exits. Projection intents remain in a legacy terminal state on disk so an older daemon cannot execute them as prompts. Sender projection IDs are indexed once per loaded journal and checked under the append lock; streamed tokens do not scan message history.
This PR exceeds the usual size target because both journal projections, trust checks, queue/hold restoration, crash recovery, protocol artifacts, and their regression tests form one persistence state machine. Splitting those repairs would leave accepted messages with inconsistent identities or missing outgoing copies.
Validation:
- Red on base: run 40783458313, job
123150654526. Kept the integration test and restored production sources from1f0f687b2f32a9f506e19e7f683e8362fd3da6b2; compilation passed, then the test failed with zero message events where one was required. - Canonical Rust protocol regeneration passed in run 40783414409.
- Focused compile and tests passed at
dc8555e18e7348caf679ef9bfd8092c357fd34a0: run 40786663556, job123161460427; all seven command groups passed, including the exact concurrent peer retry regression and fullsend_async/lane_reportsfiles. - Web protocol generation and generated-output check passed: run 40784480452, job
123154131317. - Clippy (
--all-targets -- -D warnings) and all eightsend_asynctests passed with the final cleanup patch applied before push: run 40788895404, job123168900945. Final exact-head verification passed atefa6dd9347c5924c39e85a5498b6cc39e1608cfd: run 40789469866, job123170805193(Clippy, all eightsend_async, all twentylane_reports, message/forward/recovery/acceptance unit groups, and protocol snapshot). A subsequent independent review blocked that head for target-unscoped sender deduplication; the current repair scopes sender dedupe and recovery by target session.
Since the helper took this PR over (rebased onto feat-cmux-next as one commit, then fixes):
- The record's durable message copy (
_acpmuxMessage) now sits beside_meta.provenanceas_meta._acpmuxMessageinstead of inside it, so the provenance clients read is unchanged (prompt_provenance::marked/recorded; a digest's joined copies are kept by position). Inside it, seven existing provenance tests failed. - An acceptance (
_acpmux/prompt_accepted) now also names the message its journal kept and that message's sender session (messageId,messageSenderId). A forward records its outgoing copy only when both match and the answer is not a duplicate; otherwise its intent reads the receiver's journal. Forward intents are keyed by message id as well. - Live
_acpmux/messagenotices are routed withpeerlike_acpmux/event. - Focused green at b5e5275a: run 40866830045 (fmt, clippy -D warnings,
cargo test -p acpmuxwith only the 9 failures base bd54c4e8 has in run 40848053990,send_async5/5).
No capture: this adds protocol events and generated types; it changes no rendered UI.
Review follow-ups (adopting helper, later rounds):
- Per-owner message notices: a connection's dedupe of
_acpmux/messagenotices is kept per (owner, raw id), so a peer's chat that keeps the raw id of one here (an export and import keeps ids) is never silenced by it. It is bounded per chat: at most 4096 heard seqs above a low-water mark (server::MessageNotices, testa_long_lived_chat_keeps_a_bounded_count). Only the live stream, which comes in seq order, treats a seq at or below the mark as heard. A session/load replay checks the kept seqs alone, so a replay record the bound forgot may be sent twice but never goes missing (testa_replay_record_below_a_risen_floor_still_goes, red at 1c4b8399 in run 41014574308, green at 41d7d818 in run 41015022929). - Per-peer sender copies: a sender's copy names the peer that holds the receiving chat (
_acpmuxTargetPeer), and the journal's index keys copies by (peer, chat, message id). A copy written before this change, with no peer named, keeps its old key. - Older peers: a sender copy and its forward intent need the peer to list
session-messages-v1, since an older peer records no message (testa_send_to_an_older_peer_records_no_sender_copy). - Recovery vs. queue edits and withdrawals (a review question, checked and left as is):
recover_message_eventsreadsqueuedanduser_messagerecords and notqueue_updatedorqueue_removed. It recreates nothing the live path did not already write. The live path records the message when the prompt is accepted, and a queued prompt is accepted when it queues (turns.rson_accepted->record_message_context, called throughqueue.rsaccept(...)withqueued: true). An edit or a withdrawal never deletes that record (settle_removed,queue_edit). Recovery rebuilds each copy from the_acpmuxMessagestored with thequeuedrecord (message_context_from_provenance), which holds the text as accepted, not as edited.append_or_repairkeeps one copy per message id, so recovery adds only a copy a crash lost between the durable prompt record and that acceptance write. A queue edit drops the copy from the edited run (queue_edit.rssetsmessage_id/message_senderto None and keeps the provenance only for the author's own edit), so the edited text never becomes a message. - Merges of feat-cmux-next resolved by hand:
docs/acpmux-schema.json(the transcript category keeps the base's text and adds_acpmux/message),prompt_provenance.rsandpeer_forward.rsname_sender(the PR's daemon-authoredmessageSenderbeside the base's boundfromSessionId/fromHarness),hub/queue.rs(the base's carried-turn branch, with the PR's message options restored into thePromptRun).
Agent session: agent session