Skip to main content

agent_doc_core/
crdt_sync.rs

1//! State-vector sync protocol primitive (`#crdtauth1sv`).
2//!
3//! The deferred half of `#crdtauth1`. Where [`crate::crdt::merge_by_component`]
4//! rebuilds throwaway yrs docs from text and runs a whole-`.yrs`-snapshot merge
5//! every cycle, this module models the **incremental per-replica state-vector
6//! exchange** the CRDT-authority plan calls for
7//! (`tasks/agent-doc/plan-crdt-authority-model.md`, phase 2):
8//!
9//! - Each [`ReplicaState`] keeps its yrs `Doc` **across cycles** (a durable
10//!   replica), instead of being rebuilt from text each merge.
11//! - A sync exchanges only the updates the other side is **missing**:
12//!   `peer.encode_state_as_update(my_state_vector)`. The state vector is a compact
13//!   per-client causal summary, not the whole document — so a sync ships a delta,
14//!   not a snapshot.
15//! - Convergence is **commutative, associative, idempotent**, and yrs buffers an
16//!   update whose causal dependencies have not arrived yet (causal buffering), so
17//!   out-of-order / lagged delivery self-heals once the gap is filled — never a
18//!   permanent conflict, never data loss.
19//!
20//! This is the protocol **primitive + tests** only. Rewiring the live
21//! `merge_by_component` call sites onto it and wiring the FFI-node ↔ supervisor
22//! channel are follow-up rungs. The authority gate (sync only under
23//! `CrdtAuthority::MultiReplica`; the `GitAuthoritative` / ephemeral path keeps
24//! rebuilding from git) lives in `agent-doc-orchestration` over this primitive,
25//! since the authority layer lives there.
26
27use anyhow::Result;
28use yrs::updates::decoder::Decode;
29use yrs::updates::encoder::Encode;
30use yrs::{Doc, GetString, ReadTxn, StateVector, Text, Transact, Update};
31
32const TEXT_KEY: &str = "content";
33
34/// A durable per-replica CRDT state — one participant (an editor's FFI node, or
35/// the supervisor) in a multi-replica document session.
36///
37/// Unlike [`crate::crdt::CrdtDoc`] used by the rebuild-from-text merge, a
38/// `ReplicaState` is **kept across cycles**: it accumulates ops and exchanges
39/// only deltas with its peers via [`ReplicaState::state_vector`] /
40/// [`ReplicaState::diff`] / [`ReplicaState::apply_update`].
41pub struct ReplicaState {
42    doc: Doc,
43}
44
45impl ReplicaState {
46    /// Create a fresh empty replica with a stable `client_id`. Distinct replicas
47    /// MUST use distinct client ids so concurrent inserts order deterministically
48    /// (yrs orders concurrent inserts at the same position by ascending client
49    /// id, matching the agent-before-human convention in [`crate::crdt`]).
50    pub fn new(client_id: u64) -> Self {
51        let doc = Doc::with_client_id(client_id);
52        doc.get_or_insert_text(TEXT_KEY);
53        Self { doc }
54    }
55
56    /// Bootstrap a replica from a previously encoded state (the durable boundary
57    /// checkpoint / a peer's full snapshot on first contact).
58    pub fn from_encoded(client_id: u64, state: &[u8]) -> Result<Self> {
59        let replica = Self::new(client_id);
60        replica.apply_update(state)?;
61        Ok(replica)
62    }
63
64    /// The current converged text.
65    pub fn text(&self) -> String {
66        let text = self.doc.get_or_insert_text(TEXT_KEY);
67        let txn = self.doc.transact();
68        text.get_string(&txn)
69    }
70
71    /// Apply a local edit: delete `delete_len` chars at `offset`, then insert
72    /// `insert` there (one transaction). Mirrors [`crate::crdt::CrdtDoc::apply_edit`].
73    pub fn apply_local_edit(&self, offset: u32, delete_len: u32, insert: &str) {
74        let text = self.doc.get_or_insert_text(TEXT_KEY);
75        let mut txn = self.doc.transact_mut();
76        if delete_len > 0 {
77            text.remove_range(&mut txn, offset, delete_len);
78        }
79        if !insert.is_empty() {
80            text.insert(&mut txn, offset, insert);
81        }
82    }
83
84    /// This replica's state vector, encoded for the wire. The state vector is the
85    /// **compact per-client causal summary** (each client's clock), NOT the whole
86    /// document: a peer replies with [`diff`](Self::diff) carrying only the ops
87    /// this vector is missing.
88    pub fn state_vector(&self) -> Vec<u8> {
89        let txn = self.doc.transact();
90        txn.state_vector().encode_v1()
91    }
92
93    /// The incremental update carrying exactly the ops `their_sv` is missing
94    /// (`encode_state_as_update(their_sv)`) — a delta, never a whole-document
95    /// snapshot. This is the sync reply a replica sends a peer that announced
96    /// `their_sv`.
97    pub fn diff(&self, their_sv: &[u8]) -> Result<Vec<u8>> {
98        let sv = StateVector::decode_v1(their_sv)
99            .map_err(|e| anyhow::anyhow!("decode state vector: {e}"))?;
100        let txn = self.doc.transact();
101        Ok(txn.encode_state_as_update_v1(&sv))
102    }
103
104    /// Apply a remote update. **Idempotent** (re-applying a known update is a
105    /// no-op) and **causal-buffered** by yrs (an update whose causal dependencies
106    /// have not arrived is buffered and integrated once they do), so duplicate or
107    /// out-of-order delivery converges rather than corrupting.
108    pub fn apply_update(&self, update: &[u8]) -> Result<()> {
109        let update =
110            Update::decode_v1(update).map_err(|e| anyhow::anyhow!("decode update: {e}"))?;
111        let mut txn = self.doc.transact_mut();
112        txn.apply_update(update)
113            .map_err(|e| anyhow::anyhow!("apply update: {e}"))?;
114        Ok(())
115    }
116
117    /// The full encoded state — a durable projection / boundary checkpoint, or the
118    /// snapshot a peer needs on first contact (no shared history yet).
119    pub fn encode_state(&self) -> Vec<u8> {
120        let txn = self.doc.transact();
121        txn.encode_state_as_update_v1(&StateVector::default())
122    }
123}
124
125/// One incremental sync round between two replicas: each announces its state
126/// vector, receives only the updates it is missing, and applies them. After the
127/// round both replicas have converged. This models a single FFI-node ↔ supervisor
128/// exchange; repeated rounds (or one round per delivered op) keep live replicas in
129/// sync without ever shipping a whole-document snapshot after the first contact.
130pub fn sync(a: &ReplicaState, b: &ReplicaState) -> Result<()> {
131    let a_sv = a.state_vector();
132    let b_sv = b.state_vector();
133    // Each side computes the delta the OTHER is missing, then applies what it is
134    // told. Order between the two applies does not matter (commutative merge).
135    let a_to_b = a.diff(&b_sv)?;
136    let b_to_a = b.diff(&a_sv)?;
137    b.apply_update(&a_to_b)?;
138    a.apply_update(&b_to_a)?;
139    Ok(())
140}
141
142/// Whether `canonical` has already integrated every op the `peer` replica holds —
143/// i.e. the canonical replica is synced *through* the peer's current state vector.
144///
145/// Non-destructive: the ops the peer has beyond `canonical` are applied to a
146/// throwaway clone of `canonical`; if that changes the clone's state vector,
147/// canonical was missing them.
148fn canonical_covers(canonical: &ReplicaState, peer: &ReplicaState) -> Result<bool> {
149    let missing = peer.diff(&canonical.state_vector())?;
150    let probe = ReplicaState::from_encoded(0, &canonical.encode_state())?;
151    let before = probe.state_vector();
152    probe.apply_update(&missing)?;
153    Ok(probe.state_vector() == before)
154}
155
156/// State-vector **commit barrier** (`#crdtauth3`): is `canonical` a **consistent
157/// cut** — has it integrated every live editor's ops up to that editor's current
158/// state vector?
159///
160/// This replaces the fragile finalize **patch-ack** ("did a queued patch
161/// round-trip?") with a **state-vector ack** ("is the canonical replica synced
162/// through SV=N for every live editor?"), the structural root fix for the
163/// `no_ack` / `ipc_proof_insufficient` / post-commit-worktree-corruption class:
164/// a commit can only snapshot a state that provably holds every editor's last
165/// keystrokes, so un-propagated editor ops can never be lost at the commit
166/// instant. With no live editors the barrier is trivially satisfied (the headless
167/// / git-authoritative path).
168pub fn commit_barrier_ready(canonical: &ReplicaState, editors: &[&ReplicaState]) -> Result<bool> {
169    for editor in editors {
170        if !canonical_covers(canonical, editor)? {
171            return Ok(false);
172        }
173    }
174    Ok(true)
175}
176
177/// Drive the commit barrier: **flush every live editor's missing ops into
178/// `canonical`** ("flush all live editors to SV=N"), then confirm the barrier is
179/// satisfied. After this returns `Ok(true)`, a snapshot of `canonical`
180/// ([`ReplicaState::encode_state`]) is a consistent cut safe to write to git.
181///
182/// This is the quiescence point `finalize` should snapshot at: instead of hoping
183/// a patch ACK round-tripped, it provably holds every editor's ops.
184pub fn flush_to_commit_barrier(
185    canonical: &ReplicaState,
186    editors: &[&ReplicaState],
187) -> Result<bool> {
188    for editor in editors {
189        let missing = editor.diff(&canonical.state_vector())?;
190        canonical.apply_update(&missing)?;
191    }
192    commit_barrier_ready(canonical, editors)
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    fn empty_sv() -> Vec<u8> {
200        ReplicaState::new(999).state_vector()
201    }
202
203    #[test]
204    fn incremental_sync_converges_divergent_edits() {
205        let base = ReplicaState::new(1);
206        base.apply_local_edit(0, 0, "hello world");
207        let snapshot = base.encode_state();
208
209        let a = ReplicaState::from_encoded(1, &snapshot).unwrap();
210        let b = ReplicaState::from_encoded(2, &snapshot).unwrap();
211        // Concurrent, non-overlapping edits: a prepends, b appends.
212        a.apply_local_edit(0, 0, "A: ");
213        let b_len = b.text().chars().count() as u32;
214        b.apply_local_edit(b_len, 0, " [B]");
215
216        sync(&a, &b).unwrap();
217        assert_eq!(a.text(), b.text(), "replicas converge after one sync round");
218        assert!(a.text().contains("A: ") && a.text().contains("[B]"));
219    }
220
221    #[test]
222    fn diff_ships_only_missing_delta_not_whole_snapshot() {
223        let base = ReplicaState::new(1);
224        base.apply_local_edit(0, 0, "the quick brown fox jumps over the lazy dog");
225        let snapshot = base.encode_state();
226        let a = ReplicaState::from_encoded(1, &snapshot).unwrap();
227        let b = ReplicaState::from_encoded(2, &snapshot).unwrap();
228
229        // b already knows the shared base — a's delta for b carries only NEW ops.
230        a.apply_local_edit(0, 0, "X");
231        let delta = a.diff(&b.state_vector()).unwrap();
232        let full = a.encode_state();
233        assert!(
234            delta.len() < full.len(),
235            "incremental delta ({}) must be smaller than the whole snapshot ({})",
236            delta.len(),
237            full.len()
238        );
239        // Applying just that delta converges b.
240        b.apply_update(&delta).unwrap();
241        assert_eq!(a.text(), b.text());
242    }
243
244    #[test]
245    fn apply_update_is_idempotent() {
246        let base = ReplicaState::new(1);
247        base.apply_local_edit(0, 0, "content");
248        let a = ReplicaState::from_encoded(1, &base.encode_state()).unwrap();
249        let b = ReplicaState::from_encoded(2, &base.encode_state()).unwrap();
250        a.apply_local_edit(0, 0, "PREFIX ");
251        let delta = a.diff(&b.state_vector()).unwrap();
252
253        b.apply_update(&delta).unwrap();
254        let once = b.text();
255        // Re-deliver the SAME update — must be a no-op (no duplicated insert).
256        b.apply_update(&delta).unwrap();
257        assert_eq!(b.text(), once, "re-applying a known update is idempotent");
258    }
259
260    #[test]
261    fn out_of_order_updates_self_heal_via_causal_buffering() {
262        let a = ReplicaState::new(1);
263        a.apply_local_edit(0, 0, "first");
264        // u1 = everything a has so far (delta against an empty peer).
265        let u1 = a.diff(&empty_sv()).unwrap();
266        let sv_after_u1 = a.state_vector();
267        // a's second op, captured as a delta that causally DEPENDS on u1.
268        let a_len = a.text().chars().count() as u32;
269        a.apply_local_edit(a_len, 0, " second");
270        let u2 = a.diff(&sv_after_u1).unwrap();
271
272        // Deliver OUT OF ORDER: u2 (which depends on u1) before u1.
273        let b = ReplicaState::new(2);
274        b.apply_update(&u2).unwrap(); // buffered by yrs: deps missing
275        assert_ne!(
276            b.text(),
277            a.text(),
278            "u2 alone cannot apply — its deps are missing"
279        );
280        b.apply_update(&u1).unwrap(); // fills the gap → buffered u2 integrates
281        assert_eq!(
282            b.text(),
283            a.text(),
284            "out-of-order delivery self-heals once causal deps arrive"
285        );
286    }
287
288    #[test]
289    fn bidirectional_sync_converges_and_keeps_both_contributions() {
290        let a = ReplicaState::new(1);
291        a.apply_local_edit(0, 0, "alpha");
292        let b = ReplicaState::new(2);
293        b.apply_local_edit(0, 0, "beta");
294        sync(&a, &b).unwrap();
295        assert_eq!(a.text(), b.text());
296        assert!(a.text().contains("alpha") && a.text().contains("beta"));
297    }
298
299    #[test]
300    fn repeated_sync_is_stable_no_op_after_convergence() {
301        let a = ReplicaState::new(1);
302        a.apply_local_edit(0, 0, "stable");
303        let b = ReplicaState::new(2);
304        sync(&a, &b).unwrap();
305        let converged = a.text();
306        // A second round with no new ops must not change anything.
307        sync(&a, &b).unwrap();
308        assert_eq!(a.text(), converged);
309        assert_eq!(b.text(), converged);
310    }
311
312    #[test]
313    fn encode_state_roundtrips_text() {
314        let a = ReplicaState::new(7);
315        a.apply_local_edit(0, 0, "roundtrip me");
316        let restored = ReplicaState::from_encoded(7, &a.encode_state()).unwrap();
317        assert_eq!(restored.text(), "roundtrip me");
318    }
319
320    #[test]
321    fn commit_barrier_blocks_until_editor_ops_are_flushed() {
322        let canonical = ReplicaState::new(1);
323        canonical.apply_local_edit(0, 0, "base");
324        // An editor branches from canonical and types locally (un-propagated).
325        let editor = ReplicaState::from_encoded(2, &canonical.encode_state()).unwrap();
326        let len = editor.text().chars().count() as u32;
327        editor.apply_local_edit(len, 0, " typed");
328
329        // The barrier is NOT ready: a commit now would lose the editor's keystrokes.
330        assert!(
331            !commit_barrier_ready(&canonical, &[&editor]).unwrap(),
332            "barrier must block while an editor has un-propagated ops"
333        );
334
335        // Flush → ready; canonical is now a consistent cut holding the editor's ops.
336        assert!(flush_to_commit_barrier(&canonical, &[&editor]).unwrap());
337        assert!(commit_barrier_ready(&canonical, &[&editor]).unwrap());
338        assert!(canonical.text().contains("typed"));
339
340        // The snapshot at the barrier round-trips to the same text (consistent cut).
341        let restored = ReplicaState::from_encoded(9, &canonical.encode_state()).unwrap();
342        assert_eq!(restored.text(), canonical.text());
343    }
344
345    #[test]
346    fn commit_barrier_is_trivially_ready_with_no_live_editors() {
347        // Headless / git-authoritative: no editor replicas → nothing to lose.
348        let canonical = ReplicaState::new(1);
349        canonical.apply_local_edit(0, 0, "headless");
350        assert!(commit_barrier_ready(&canonical, &[]).unwrap());
351    }
352
353    #[test]
354    fn commit_barrier_reopens_on_a_new_unsynced_op() {
355        let canonical = ReplicaState::new(1);
356        let e1 = ReplicaState::new(2);
357        let e2 = ReplicaState::new(3);
358        e1.apply_local_edit(0, 0, "one");
359        e2.apply_local_edit(0, 0, "two");
360        assert!(flush_to_commit_barrier(&canonical, &[&e1, &e2]).unwrap());
361        assert!(canonical.text().contains("one") && canonical.text().contains("two"));
362
363        // A fresh un-synced keystroke on e1 re-opens the barrier.
364        e1.apply_local_edit(0, 0, "Z");
365        assert!(
366            !commit_barrier_ready(&canonical, &[&e1, &e2]).unwrap(),
367            "a new editor op after the cut must re-open the barrier"
368        );
369    }
370}