acpmux peer snapshots (6): the _acpmux/peer_snapshot read hook

Merged
TE
9d ago
+353-0

Lawrence's list item, word for word: "start new VMs from acp chat - decouple persistence of acp trajectories from the VM itself, in case the VM is resuming".

Part of the client-snapshot piece of manaflow-ai/cmuxterm-hq#4381 ("Persistence, pause and resume, and history hydration"): the Mac's acpmux keeps a durable, bounded copy of each peer session's events, so the app paints a paused or offline peer chat at once, marked possibly stale, and catches up from the host with afterSeq. The host's journal stays the authority; the copy is not a write lease, an execution checkpoint or a backup. The shared fenced-writer database from #4381 is not part of this work.

This replaces #25323 (store) and #25378 (hook and pane), split so each piece lands on its own under about 800 changed lines, tests included. The series:

  1. the on-disk form of a copy (#25448, landed)
  2. the store's thread: bounds, writes, removals (#25445)
    2t. the store's tests (#25505)
  3. what starts or grows a copy: generations, joins, links (#25446)
  4. purges, removed peers, the queue's bound (#25447)
  5. the hub's taps (wiring) (#25449)
  6. the read hook _acpmux/peer_snapshot, which the VM start/resume work calls too (#25473)
  7. the pane's offline banner and the reply's reading (#25474)
  8. the pane paints the copy, then catches up (#25475)
    8t. the pane's tests (#25506)
    8t2. tests of 8's attach fixes (#25620)

This PR (6): the _acpmux/peer_snapshot read hook

This is the one hook a client reads a peer session's kept copy through, whether or not the peer answers. Clients are the app's agent pane (7 and 8) and the VM start and resume path.

_acpmux/peer_snapshot {sessionId, host?, limit?, maxBytes?} returns:

{peer, sessionId, qualifiedSessionId, events, lastSeq, hostLastSeq, generation, capturedAt, hasMore, stale: true, summary}
  • Session. sessionId is an id or HOST/name. With host, it is the peer's own id, which covers a lease that has ended.
  • Records. It returns the copy's newest limit records (default 400, at most 1000) within maxBytes, oldest first.
  • Whole history. hasMore is false only when the copy starts at seq 1 and every record was returned.
  • Staleness. The reply is always stale: true. summary is the row now if the peer still lists the session, else the row the copy kept, with no retry countdown.
  • Generation. A generation other than the copy's (the row's createdAt now) reads nothing and drops nothing. The listing decides which copy goes.
  • Limits. A limit that is not a whole number from 1 is refused: 0, -1, 1.5, true and "2" are all refused (H4, from the last review of #25378).
  • Where it is answered. The store's thread answers from memory or the copy's file (Op::Read). A read reserves queue room like a capture; with the queue full, the reply says nothing is kept. The caller waits at most 5 s. The method is answered locally and never forwarded (SESSION_SCOPED_EXCLUDED).
  • Docs. It is documented in the acpmux README. The protocol spec and the generated methods.ts list it.

Findings from the earlier reviews this piece answers

These are H1 to H4 of the ledger (Codex gpt-6-astra xhigh, rounds 1 to 6 of #25378):

  • a partial read that claimed full history;
  • an obsolete read that deleted a newer copy;
  • limit: 0 answered with one event;
  • a negative, fractional or boolean limit taken as omitted;
  • 2's queued ops (Queued): a read the thread never took (it ended) answers its reader at once that there is nothing.
  • this PR's sixth review: a read sends every loss not sent yet before it, as flush_now does, so a read that starts the thread again never reads what a lost purge removes (H7).
  • this PR's eighth review (nonblocking): the README and docs/acpmux-schema.json describe the store as it is, one file a session under peer-snapshots/ (H8).

Tests

On the fleet:

  • read_tests.rs: a down peer's copy is read whole, then within a limit; bad limits are refused; the copy is read again after this daemon restarts with the peer still down; a loss not sent yet goes before a read of its peer.
  • The peer_snapshot tests pass (48).
  • cargo fmt --check and cargo clippy -p acpmux --all-targets -D warnings are clean.
  • bun run acpmux-protocol:check is current.
  • Release compile (cmux-next, Xcode 26.6) on the fleet through the controller at head 7b544f5520ec3a866d8ee668ec88feb054a1bf1a: green, step e0218986d73056c4ab66a5d7.
  • Rebased onto 5 as landed (a8a6f7f237f): the full cargo test -p acpmux suite passes except the base reds below; clippy and fmt are clean.
  • Review: Codex (gpt-6-astra, xhigh) SHIP at 7b544f5520ec3a866d8ee668ec88feb054a1bf1a, a confirming review of the rebase and the one-line test change it took (RemoteRows has no clear, so the test empties the rows with retain).

No capture: this PR changes no UI. Its only webview file is the generated protocol method list (methods.ts), which gains _acpmux/peer_snapshot; the pane first reads it in 7 and 8, whose PRs carry the captures.

Timers, threads and loops

None new. The read waits on the store's thread (2) for at most ANSWER_WITHIN (5 s).

Base reds, not from this PR (they fail the same way on feat-cmux-next): agent_tools::tests::own_servers_run_under_the_daemon_prefix, daemon_chat_open, ephemeral_run::a_probe_from_an_agents_shell_is_not_its_subagent, hermetic_daemon_guard (2), and the flaky session_pool::pooled_sessions_end_with_the_daemon_and_after_a_crash, subagent_retry, prompt_supersede::a_prompt_queued_before_a_restart_is_still_replaced (fails 1 run in 2 of the full suite on feat-cmux-next) and cli::run::launch::tests::a_launched_session_never_gets_ask_user_question (its tests share one pid-named brief file, so a read can see another test's truncate; it passes alone and in later full runs). The gallery health and guard status reds are on feat-cmux-next too.

Agent session: agent session

šŸ¤– Generated with Claude Code