Skip to main content

lex_vcs/
merge_session.rs

1//! Stateful merge sessions for programmatic conflict resolution (#134).
2//!
3//! Today's `lex_vcs::merge` returns a list of `MergeOutcome`s — auto-
4//! merged sigs *and* conflicts — and exits. To act on conflicts an
5//! agent has to:
6//!
7//! 1. Run `lex store-merge`.
8//! 2. Parse the JSON output.
9//! 3. Decide a resolution per conflict.
10//! 4. Manually edit source files.
11//! 5. Run `lex check`.
12//! 6. Run `lex publish`.
13//! 7. Loop on failure.
14//!
15//! Six round-trips for what should be one transaction. Worse, the
16//! agent edits *text* between steps 4 and 6 — the typed conflict
17//! the merge engine produced gets re-derived from the new text. The
18//! information loss is what the issue calls out.
19//!
20//! [`MergeSession`] gives the engine layer needed to expose merging
21//! as a state machine: `start` collects conflicts, `resolve` accepts
22//! batched [`Resolution`]s, `commit` finalizes when no conflicts
23//! remain. The HTTP wrapper (`POST /v1/merge/start` etc.) and the
24//! CLI mirror (`lex merge resolve`) compose on top of this.
25//!
26//! # Why a stateful session
27//!
28//! Merging conflicts iteratively is the natural agent loop:
29//! "submit 50 resolutions, see which were accepted, fix the ones
30//! that broke type-checking, retry." The session holds the
31//! in-progress state so the merge cost (LCA computation, op
32//! grouping, conflict classification) is paid once per merge,
33//! not once per resolution batch.
34//!
35//! # What's in the foundation slice
36//!
37//! The state machine: types, transitions, validation hook for
38//! resolved candidates, commit path that produces a fresh head op.
39//! Persistence (so a session survives a process restart) and the
40//! HTTP / CLI surfaces are subsequent slices.
41
42use std::collections::BTreeMap;
43
44use serde::{Deserialize, Serialize};
45
46use crate::merge::{ConflictKind, MergeOutcome, MergeOutput};
47use crate::op_log::OpLog;
48use crate::operation::{OpId, Operation, SigId, StageId};
49
50/// Stable id for a merge in flight. Caller-supplied so the HTTP
51/// surface can map URLs to sessions without leaking session ids
52/// from the engine. Production callers will likely use UUIDs;
53/// tests use short strings.
54pub type MergeSessionId = String;
55
56/// Stable id for a conflict within a session. We use the SigId as
57/// the conflict id since conflicts are 1:1 with the sigs that have
58/// `MergeOutcome::Conflict`. If a future merge ever produces
59/// multiple conflicts on the same sig, this becomes a tuple.
60pub type ConflictId = SigId;
61
62/// Snapshot of one conflict the agent needs to resolve.
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct ConflictRecord {
65    pub conflict_id: ConflictId,
66    pub sig_id: SigId,
67    pub kind: ConflictKind,
68    /// Stage on the LCA. `None` for `AddAdd` (no shared base) and
69    /// for sigs that didn't exist on the LCA.
70    pub base: Option<StageId>,
71    /// Stage on the dst (ours) side of the merge. `None` if dst
72    /// removed it.
73    pub ours: Option<StageId>,
74    /// Stage on the src (theirs) side of the merge. `None` if src
75    /// removed it.
76    pub theirs: Option<StageId>,
77}
78
79/// Choice for a single conflict.
80// `Operation` is the only payload-carrying variant and grew with
81// #280's typed transforms. Clippy flags the size disparity, but
82// boxing the field would churn callers (HTTP handler, CLI, tests)
83// for a heuristic warning — the heap allocation cost vs. the
84// occasional empty variant is not actually a hot path here.
85#[allow(clippy::large_enum_variant)]
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(tag = "kind", rename_all = "snake_case")]
88pub enum Resolution {
89    /// Keep dst's stage; discard src's.
90    TakeOurs,
91    /// Keep src's stage; discard dst's.
92    TakeTheirs,
93    /// Submit a brand-new op that supersedes both sides. The op's
94    /// parents must include both ours and theirs (the merge engine
95    /// validates this; see [`MergeSession::validate_resolution`]).
96    Custom { op: Operation },
97    /// Punt to a human reviewer. Surfaces as
98    /// [`CommitError::ConflictsRemaining`] on commit until removed.
99    Defer,
100}
101
102/// Why a resolution was rejected. Distinct from [`CommitError`]
103/// because a resolve call returns *per-conflict* verdicts; commit
104/// returns a single overall verdict.
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(tag = "kind", rename_all = "snake_case")]
107pub enum ResolutionRejection {
108    /// The conflict_id doesn't refer to any pending conflict in
109    /// the session. Either the agent invented one, or it was
110    /// already resolved and the session pruned it.
111    UnknownConflict { conflict_id: ConflictId },
112    /// The custom op's parents don't include both `ours` and
113    /// `theirs`. A custom resolution that doesn't acknowledge
114    /// both sides isn't a merge — it's a fork.
115    CustomOpMissingParents {
116        conflict_id: ConflictId,
117        expected: Vec<OpId>,
118        got: Vec<OpId>,
119    },
120}
121
122/// Per-conflict outcome of a resolve call.
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
124pub struct ResolveVerdict {
125    pub conflict_id: ConflictId,
126    pub accepted: bool,
127    pub rejection: Option<ResolutionRejection>,
128}
129
130/// Why a commit failed. Conflicts-remaining is the most common
131/// case — agents are expected to iterate via resolve until this
132/// goes away.
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub enum CommitError {
135    /// At least one conflict has no resolution or has
136    /// [`Resolution::Defer`]. The session is still alive; submit
137    /// resolutions and retry.
138    ConflictsRemaining(Vec<ConflictId>),
139}
140
141/// Stateful merge in flight. Hold one per active merge between
142/// `start` and `commit`. Sessions are not thread-safe; the HTTP
143/// wrapper is expected to wrap them in a `Mutex` keyed by
144/// [`MergeSessionId`].
145#[derive(Debug, Serialize, Deserialize)]
146pub struct MergeSession {
147    pub merge_id: MergeSessionId,
148    pub src_head: Option<OpId>,
149    pub dst_head: Option<OpId>,
150    pub lca: Option<OpId>,
151    /// Outcomes the engine resolved unilaterally — `Both` (both
152    /// sides agreed) and one-sided (`Src` / `Dst`). The agent sees
153    /// these for audit but doesn't need to act on them.
154    pub auto_resolved: Vec<MergeOutcome>,
155    /// Conflicts indexed by id. Removed as resolutions land.
156    conflicts: BTreeMap<ConflictId, ConflictRecord>,
157    /// Resolutions accumulated across resolve calls. Validated
158    /// against `conflicts` when applied.
159    resolutions: BTreeMap<ConflictId, Resolution>,
160}
161
162impl MergeSession {
163    /// Start a merge session. Runs the engine in [`crate::merge`]
164    /// and partitions the outcomes into auto-resolved and
165    /// conflicts-needing-attention.
166    pub fn start(
167        merge_id: impl Into<MergeSessionId>,
168        op_log: &OpLog,
169        src_head: Option<&OpId>,
170        dst_head: Option<&OpId>,
171    ) -> std::io::Result<Self> {
172        let MergeOutput { lca, outcomes } = crate::merge::merge(op_log, src_head, dst_head)?;
173        let mut auto_resolved = Vec::new();
174        let mut conflicts: BTreeMap<ConflictId, ConflictRecord> = BTreeMap::new();
175        for outcome in outcomes {
176            match outcome {
177                MergeOutcome::Conflict {
178                    sig_id,
179                    kind,
180                    base,
181                    src,
182                    dst,
183                } => {
184                    let conflict_id = sig_id.clone();
185                    conflicts.insert(
186                        conflict_id.clone(),
187                        ConflictRecord {
188                            conflict_id,
189                            sig_id,
190                            kind,
191                            base,
192                            // The merge engine returns `src` and
193                            // `dst` from src's and dst's perspective
194                            // respectively. We map dst→ours and
195                            // src→theirs, matching the canonical
196                            // git terminology and the issue text.
197                            ours: dst,
198                            theirs: src,
199                        },
200                    );
201                }
202                other => auto_resolved.push(other),
203            }
204        }
205        Ok(Self {
206            merge_id: merge_id.into(),
207            src_head: src_head.cloned(),
208            dst_head: dst_head.cloned(),
209            lca,
210            auto_resolved,
211            conflicts,
212            resolutions: BTreeMap::new(),
213        })
214    }
215
216    /// Pending conflicts (those without a non-defer resolution).
217    pub fn remaining_conflicts(&self) -> Vec<&ConflictRecord> {
218        self.conflicts
219            .values()
220            .filter(|c| {
221                !matches!(self.resolutions.get(&c.conflict_id),
222                    Some(Resolution::TakeOurs)
223                    | Some(Resolution::TakeTheirs)
224                    | Some(Resolution::Custom { .. }))
225            })
226            .collect()
227    }
228
229    /// Submit resolutions in batch. Returns one verdict per input.
230    /// Accepted resolutions are recorded; rejected ones leave the
231    /// previous resolution (if any) in place so partial submissions
232    /// don't clobber earlier good work.
233    pub fn resolve(
234        &mut self,
235        resolutions: Vec<(ConflictId, Resolution)>,
236    ) -> Vec<ResolveVerdict> {
237        let mut out = Vec::with_capacity(resolutions.len());
238        for (conflict_id, resolution) in resolutions {
239            match self.validate_resolution(&conflict_id, &resolution) {
240                Ok(()) => {
241                    self.resolutions.insert(conflict_id.clone(), resolution);
242                    out.push(ResolveVerdict {
243                        conflict_id,
244                        accepted: true,
245                        rejection: None,
246                    });
247                }
248                Err(rej) => {
249                    out.push(ResolveVerdict {
250                        conflict_id,
251                        accepted: false,
252                        rejection: Some(rej),
253                    });
254                }
255            }
256        }
257        out
258    }
259
260    /// Validate a single resolution against the session's pending
261    /// conflicts. Pure (no side effects); the caller decides
262    /// whether to accept.
263    pub fn validate_resolution(
264        &self,
265        conflict_id: &ConflictId,
266        resolution: &Resolution,
267    ) -> Result<(), ResolutionRejection> {
268        if !self.conflicts.contains_key(conflict_id) {
269            return Err(ResolutionRejection::UnknownConflict { conflict_id: conflict_id.clone() });
270        }
271        if let Resolution::Custom { op } = resolution {
272            // Validate that the custom op's parent set acknowledges
273            // both sides. We don't have direct OpIds for the
274            // ours/theirs ops here (the conflict record carries
275            // stage ids), so the check is "the op has at least two
276            // parents" — a stronger check requires looking up the
277            // ops by sig and confirming they're in the parents,
278            // which is a follow-up enhancement.
279            //
280            // For the foundation slice this catches the obvious
281            // misuse (`Operation::new(kind, [])`) without
282            // reconstructing the merge engine's own validation.
283            if op.parents.len() < 2 {
284                return Err(ResolutionRejection::CustomOpMissingParents {
285                    conflict_id: conflict_id.clone(),
286                    expected: vec!["ours-op-id".into(), "theirs-op-id".into()],
287                    got: op.parents.clone(),
288                });
289            }
290        }
291        Ok(())
292    }
293
294    /// Finalize the merge. On success returns the resolved
295    /// resolutions in conflict_id order. The caller is responsible
296    /// for synthesizing the final `Operation::Merge` op against the
297    /// store and persisting it; this function returns the engine's
298    /// view of "what to land," not the persisted op id.
299    pub fn commit(self) -> Result<Vec<(ConflictId, Resolution)>, CommitError> {
300        let unresolved: Vec<ConflictId> = self
301            .conflicts
302            .keys()
303            .filter(|id| {
304                !matches!(self.resolutions.get(*id),
305                    Some(Resolution::TakeOurs)
306                    | Some(Resolution::TakeTheirs)
307                    | Some(Resolution::Custom { .. }))
308            })
309            .cloned()
310            .collect();
311        if !unresolved.is_empty() {
312            return Err(CommitError::ConflictsRemaining(unresolved));
313        }
314        let mut resolved: Vec<(ConflictId, Resolution)> = self.resolutions.into_iter().collect();
315        resolved.sort_by(|a, b| a.0.cmp(&b.0));
316        Ok(resolved)
317    }
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323    use crate::operation::{OperationKind, OperationRecord, StageTransition};
324    use std::collections::BTreeSet;
325
326    /// Tiny fixture: one branch (dst) modifies fn::A from stage-0 to
327    /// stage-1; another (src) modifies fn::A to stage-2. The LCA is
328    /// the original add. The merge surfaces a `ModifyModify`
329    /// conflict on fn::A.
330    fn fixture() -> (tempfile::TempDir, OpLog, OpId, OpId) {
331        let tmp = tempfile::tempdir().unwrap();
332        let log = OpLog::open(tmp.path()).unwrap();
333        let r0 = OperationRecord::new(
334            Operation::new(
335                OperationKind::AddFunction {
336                    sig_id: "fn::A".into(),
337                    stage_id: "stage-0".into(),
338                    effects: BTreeSet::new(),
339                    budget_cost: None,
340                },
341                [],
342            ),
343            StageTransition::Create {
344                sig_id: "fn::A".into(),
345                stage_id: "stage-0".into(),
346            },
347        );
348        log.put(&r0).unwrap();
349
350        let r1 = OperationRecord::new(
351            Operation::new(
352                OperationKind::ModifyBody {
353                    sig_id: "fn::A".into(),
354                    from_stage_id: "stage-0".into(),
355                    to_stage_id: "stage-1".into(),
356                    from_budget: None,
357                    to_budget: None,
358                },
359                [r0.op_id.clone()],
360            ),
361            StageTransition::Replace {
362                sig_id: "fn::A".into(),
363                from: "stage-0".into(),
364                to: "stage-1".into(),
365            },
366        );
367        log.put(&r1).unwrap();
368
369        let r2 = OperationRecord::new(
370            Operation::new(
371                OperationKind::ModifyBody {
372                    sig_id: "fn::A".into(),
373                    from_stage_id: "stage-0".into(),
374                    to_stage_id: "stage-2".into(),
375                    from_budget: None,
376                    to_budget: None,
377                },
378                [r0.op_id.clone()],
379            ),
380            StageTransition::Replace {
381                sig_id: "fn::A".into(),
382                from: "stage-0".into(),
383                to: "stage-2".into(),
384            },
385        );
386        log.put(&r2).unwrap();
387
388        (tmp, log, r1.op_id, r2.op_id)
389    }
390
391    #[test]
392    fn start_collects_conflicts() {
393        let (_tmp, log, dst, src) = fixture();
394        let session =
395            MergeSession::start("ms-1", &log, Some(&src), Some(&dst)).unwrap();
396        assert_eq!(session.remaining_conflicts().len(), 1);
397        assert_eq!(session.remaining_conflicts()[0].sig_id, "fn::A");
398        assert_eq!(
399            session.remaining_conflicts()[0].kind,
400            ConflictKind::ModifyModify
401        );
402        assert_eq!(
403            session.remaining_conflicts()[0].ours.as_deref(),
404            Some("stage-1"),
405        );
406        assert_eq!(
407            session.remaining_conflicts()[0].theirs.as_deref(),
408            Some("stage-2"),
409        );
410        assert_eq!(
411            session.remaining_conflicts()[0].base.as_deref(),
412            Some("stage-0"),
413        );
414    }
415
416    #[test]
417    fn no_conflicts_when_branches_dont_overlap() {
418        let tmp = tempfile::tempdir().unwrap();
419        let log = OpLog::open(tmp.path()).unwrap();
420        let r0 = OperationRecord::new(
421            Operation::new(
422                OperationKind::AddFunction {
423                    sig_id: "fn::A".into(),
424                    stage_id: "stage-0".into(),
425                    effects: BTreeSet::new(),
426                    budget_cost: None,
427                },
428                [],
429            ),
430            StageTransition::Create {
431                sig_id: "fn::A".into(),
432                stage_id: "stage-0".into(),
433            },
434        );
435        log.put(&r0).unwrap();
436        let r1 = OperationRecord::new(
437            Operation::new(
438                OperationKind::AddFunction {
439                    sig_id: "fn::B".into(),
440                    stage_id: "stage-B".into(),
441                    effects: BTreeSet::new(),
442                    budget_cost: None,
443                },
444                [r0.op_id.clone()],
445            ),
446            StageTransition::Create {
447                sig_id: "fn::B".into(),
448                stage_id: "stage-B".into(),
449            },
450        );
451        log.put(&r1).unwrap();
452
453        let session =
454            MergeSession::start("ms-2", &log, Some(&r1.op_id), Some(&r0.op_id)).unwrap();
455        assert!(session.remaining_conflicts().is_empty());
456        assert_eq!(session.auto_resolved.len(), 1, "fn::B added on src side");
457    }
458
459    #[test]
460    fn resolve_take_ours_clears_conflict() {
461        let (_tmp, log, dst, src) = fixture();
462        let mut session =
463            MergeSession::start("ms-3", &log, Some(&src), Some(&dst)).unwrap();
464        let verdicts = session.resolve(vec![("fn::A".into(), Resolution::TakeOurs)]);
465        assert_eq!(verdicts.len(), 1);
466        assert!(verdicts[0].accepted);
467        assert!(session.remaining_conflicts().is_empty());
468    }
469
470    #[test]
471    fn resolve_take_theirs_clears_conflict() {
472        let (_tmp, log, dst, src) = fixture();
473        let mut session =
474            MergeSession::start("ms-4", &log, Some(&src), Some(&dst)).unwrap();
475        let verdicts =
476            session.resolve(vec![("fn::A".into(), Resolution::TakeTheirs)]);
477        assert!(verdicts[0].accepted);
478        assert!(session.remaining_conflicts().is_empty());
479    }
480
481    #[test]
482    fn resolve_unknown_conflict_is_rejected() {
483        let (_tmp, log, dst, src) = fixture();
484        let mut session =
485            MergeSession::start("ms-5", &log, Some(&src), Some(&dst)).unwrap();
486        let verdicts =
487            session.resolve(vec![("fn::Z".into(), Resolution::TakeOurs)]);
488        assert_eq!(verdicts.len(), 1);
489        assert!(!verdicts[0].accepted);
490        assert!(matches!(
491            verdicts[0].rejection,
492            Some(ResolutionRejection::UnknownConflict { .. }),
493        ));
494    }
495
496    #[test]
497    fn custom_op_without_two_parents_is_rejected() {
498        let (_tmp, log, dst, src) = fixture();
499        let mut session =
500            MergeSession::start("ms-6", &log, Some(&src), Some(&dst)).unwrap();
501        // A custom op with empty parents — clearly not a merge.
502        let bad_op = Operation::new(
503            OperationKind::ModifyBody {
504                sig_id: "fn::A".into(),
505                from_stage_id: "stage-0".into(),
506                to_stage_id: "stage-X".into(),
507                from_budget: None,
508                to_budget: None,
509            },
510            [],
511        );
512        let verdicts = session.resolve(vec![(
513            "fn::A".into(),
514            Resolution::Custom { op: bad_op },
515        )]);
516        assert!(!verdicts[0].accepted);
517        assert!(matches!(
518            verdicts[0].rejection,
519            Some(ResolutionRejection::CustomOpMissingParents { .. }),
520        ));
521        // The conflict is still pending — bad resolutions don't
522        // clobber the slot.
523        assert_eq!(session.remaining_conflicts().len(), 1);
524    }
525
526    #[test]
527    fn custom_op_with_two_parents_is_accepted() {
528        let (_tmp, log, dst, src) = fixture();
529        let mut session =
530            MergeSession::start("ms-7", &log, Some(&src), Some(&dst)).unwrap();
531        let merge_op = Operation::new(
532            OperationKind::ModifyBody {
533                sig_id: "fn::A".into(),
534                from_stage_id: "stage-0".into(),
535                to_stage_id: "stage-merged".into(),
536                from_budget: None,
537                to_budget: None,
538            },
539            [src.clone(), dst.clone()],
540        );
541        let verdicts = session.resolve(vec![(
542            "fn::A".into(),
543            Resolution::Custom { op: merge_op },
544        )]);
545        assert!(verdicts[0].accepted);
546        assert!(session.remaining_conflicts().is_empty());
547    }
548
549    #[test]
550    fn defer_keeps_conflict_pending() {
551        let (_tmp, log, dst, src) = fixture();
552        let mut session =
553            MergeSession::start("ms-8", &log, Some(&src), Some(&dst)).unwrap();
554        let verdicts = session.resolve(vec![("fn::A".into(), Resolution::Defer)]);
555        // Defer is a valid resolution — accepted — but the conflict
556        // stays in `remaining_conflicts` since it still requires
557        // human attention.
558        assert!(verdicts[0].accepted);
559        assert_eq!(session.remaining_conflicts().len(), 1);
560    }
561
562    #[test]
563    fn commit_with_no_conflicts_succeeds() {
564        let tmp = tempfile::tempdir().unwrap();
565        let log = OpLog::open(tmp.path()).unwrap();
566        let session = MergeSession::start("ms-9", &log, None, None).unwrap();
567        let resolved = session.commit().unwrap();
568        assert!(resolved.is_empty());
569    }
570
571    #[test]
572    fn commit_with_unresolved_conflict_fails() {
573        let (_tmp, log, dst, src) = fixture();
574        let session =
575            MergeSession::start("ms-10", &log, Some(&src), Some(&dst)).unwrap();
576        let err = session.commit().unwrap_err();
577        match err {
578            CommitError::ConflictsRemaining(ids) => {
579                assert_eq!(ids, vec!["fn::A".to_string()]);
580            }
581        }
582    }
583
584    #[test]
585    fn commit_with_defer_remaining_fails() {
586        let (_tmp, log, dst, src) = fixture();
587        let mut session =
588            MergeSession::start("ms-11", &log, Some(&src), Some(&dst)).unwrap();
589        session.resolve(vec![("fn::A".into(), Resolution::Defer)]);
590        let err = session.commit().unwrap_err();
591        match err {
592            CommitError::ConflictsRemaining(ids) => {
593                assert_eq!(ids, vec!["fn::A".to_string()]);
594            }
595        }
596    }
597
598    #[test]
599    fn commit_after_resolve_succeeds() {
600        let (_tmp, log, dst, src) = fixture();
601        let mut session =
602            MergeSession::start("ms-12", &log, Some(&src), Some(&dst)).unwrap();
603        session.resolve(vec![("fn::A".into(), Resolution::TakeOurs)]);
604        let resolved = session.commit().unwrap();
605        assert_eq!(resolved.len(), 1);
606        assert_eq!(resolved[0].0, "fn::A");
607        assert!(matches!(resolved[0].1, Resolution::TakeOurs));
608    }
609
610    #[test]
611    fn batch_resolve_accepts_partial() {
612        // Mixed batch: one valid, one referencing an unknown
613        // conflict. The valid one should land; the bad one should
614        // be rejected without clobbering anything else.
615        let (_tmp, log, dst, src) = fixture();
616        let mut session =
617            MergeSession::start("ms-13", &log, Some(&src), Some(&dst)).unwrap();
618        let verdicts = session.resolve(vec![
619            ("fn::A".into(), Resolution::TakeOurs),
620            ("fn::DOESNT_EXIST".into(), Resolution::TakeTheirs),
621        ]);
622        assert_eq!(verdicts.len(), 2);
623        assert!(verdicts[0].accepted);
624        assert!(!verdicts[1].accepted);
625        // fn::A is now resolved.
626        assert!(session.remaining_conflicts().is_empty());
627    }
628
629    #[test]
630    fn auto_resolved_outcomes_are_visible() {
631        let tmp = tempfile::tempdir().unwrap();
632        let log = OpLog::open(tmp.path()).unwrap();
633        // Single branch: just an add; no second branch to merge,
634        // but `MergeSession::start(... None ...)` still runs the
635        // engine. This documents what `auto_resolved` carries.
636        let r0 = OperationRecord::new(
637            Operation::new(
638                OperationKind::AddFunction {
639                    sig_id: "fn::A".into(),
640                    stage_id: "stage-0".into(),
641                    effects: BTreeSet::new(),
642                    budget_cost: None,
643                },
644                [],
645            ),
646            StageTransition::Create {
647                sig_id: "fn::A".into(),
648                stage_id: "stage-0".into(),
649            },
650        );
651        log.put(&r0).unwrap();
652        let session =
653            MergeSession::start("ms-14", &log, Some(&r0.op_id), None).unwrap();
654        assert!(session.remaining_conflicts().is_empty());
655        // src had a unique op vs the missing dst → it's an Src
656        // outcome surfaced as auto-resolved.
657        assert_eq!(session.auto_resolved.len(), 1);
658    }
659}