acpmux: persist session message events

Merged
TE
6d ago
+3772-64

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 from 1f0f687b2f32a9f506e19e7f683e8362fd3da6b2; 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, job 123161460427; all seven command groups passed, including the exact concurrent peer retry regression and full send_async/lane_reports files.
  • Web protocol generation and generated-output check passed: run 40784480452, job 123154131317.
  • Clippy (--all-targets -- -D warnings) and all eight send_async tests passed with the final cleanup patch applied before push: run 40788895404, job 123168900945. Final exact-head verification passed at efa6dd9347c5924c39e85a5498b6cc39e1608cfd: run 40789469866, job 123170805193 (Clippy, all eight send_async, all twenty lane_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.provenance as _meta._acpmuxMessage instead 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/message notices are routed with peer like _acpmux/event.
  • Focused green at b5e5275a: run 40866830045 (fmt, clippy -D warnings, cargo test -p acpmux with only the 9 failures base bd54c4e8 has in run 40848053990, send_async 5/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/message notices 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, test a_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 (test a_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 (test a_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_events reads queued and user_message records and not queue_updated or queue_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.rs on_accepted -> record_message_context, called through queue.rs accept(...) with queued: true). An edit or a withdrawal never deletes that record (settle_removed, queue_edit). Recovery rebuilds each copy from the _acpmuxMessage stored with the queued record (message_context_from_provenance), which holds the text as accepted, not as edited. append_or_repair keeps 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.rs sets message_id/message_sender to 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.rs and peer_forward.rs name_sender (the PR's daemon-authored messageSender beside the base's bound fromSessionId/fromHarness), hub/queue.rs (the base's carried-turn branch, with the PR's message options restored into the PromptRun).

Agent session: agent session