acpmux peer snapshots (1/8): the on-disk form of a peer session's copy
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:
- the on-disk form of a copy
- the store's thread: bounds, writes, removals
- what starts or grows a copy: generations, joins, links
- purges, removed peers, the queue's bound
- the hub's taps (wiring)
- the read hook
_acpmux/peer_snapshot(the VM start/resume work calls it too) - 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'slastSeq, 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_BYTESor a head line pastHEAD_BYTES;read_headchecks the file's length too; and the scan reports a file pastFILE_BYTESas 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 + 1overflows. - 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.rsdeclares 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