acpmux peer snapshots (1/8): the on-disk form of a peer session's copy

Merged
TE
10d ago
+410-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 lines. The series:

  1. the on-disk form of a copy
  2. the store's thread: bounds, writes, removals
  3. what starts or grows a copy: generations, joins, links
  4. purges, removed peers, the queue's bound
  5. the hub's taps (wiring)
  6. the read hook _acpmux/peer_snapshot (the VM start/resume work calls it too)
  7. and 8. the agent pane paints the copy, then catches up.

This PR (1/8): the on-disk form of a copy

  • hub/peer_snapshot/disk.rs: one file per copy, peers/.snapshots/<hex peer>/<hex muxSessionId>.json: a head line (generation, coverage, the peer's lastSeq, capturedAt, the row), then one record a line.
  • Written whole to a temporary file renamed in (config::write_atomic: owner-only, fsync, rename), so a full disk or a crash leaves the old copy or the new one, never a mix.
  • The writer takes whole lines only and refuses a file past FILE_BYTES or a head line past HEAD_BYTES; read_head checks the file's length too; and the scan reports a file past FILE_BYTES as stray, so no file the readers would refuse is ever written or kept.
  • A record's seq is from 1 and below 2^53 (what a client's number holds), so no bound's seq + 1 overflows.
  • Read whole or not at all: a cut line, a line that does not parse, another session's head or record, or a file or head past its bound (FILE_BYTES, HEAD_BYTES; the writer and both readers hold the head line to the same bound) is not a copy.
  • Names are lowercase hex of the id's bytes, so ids that differ only in case never share a file on a case-insensitive volume. An id is at most 100 bytes, so its name plus write_atomic's longest temporary suffix (.tmp-<u32 pid>-<u64 counter>) still fits 255 bytes.
  • The scan first checks the root is this user's private directory (config::private_state_dir), never follows a symlink at any level, and reports everything it does not recognize as stray; removal never follows a symlink.
  • Not wired: hub/mod.rs declares the module with #[allow(dead_code)] until the taps land in 5/8.

No timers, observers or loops.

Findings from the earlier reviews this piece answers

D1 to D19 and S22 of the ledger (Codex gpt-6-astra xhigh, rounds 1 to 11 of #25323, and this PR's first review): torn segments and read errors (whole-file writes and reads), symlinked roots and peer directories, case-insensitive aliasing, unbounded head reads, heads the writer accepts but a restart rejects, records of another session; an id whose temporary file name passes 255 bytes, the symlink test gated to Unix (this PR's first review); the writer and scan holding the bounds, and the seq range (its second review and 2/8's first).

Tests

disk_tests.rs on the fleet (Linux, 32 vCPU), with cargo fmt --check and cargo clippy -p acpmux --all-targets -D warnings clean:

  • names;
  • whole-or-nothing reads;
  • the scan's strays, symlinks included;
  • file and head bounds;
  • the head bound shared by the writer and readers.

Risk: none at runtime, since nothing calls it yet.

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, and the flaky session_pool::pooled_sessions_end_with_the_daemon_and_after_a_crash.

Agent session: agent session

šŸ¤– Generated with Claude Code