Skip to main content

agentmux_srv/backend/
memory_reconcile.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Reconcile an agent's memory record with the provider folder its spawn is
5//! about to use (SPEC_MEMORY_FOLLOWS_THE_AGENT_2026_09_24.md §2.1.3, M3b).
6//!
7//! Runs before the provider process starts, so a new account, working
8//! directory or channel starts its first session WITH the agent's memory.
9//! Only a directory proven exclusive (`memory_dir_claims`) is touched.
10//!
11//! Per file, comparing disk, the record's head, and the version last
12//! projected into this directory:
13//! - disk equals the head → record the projection, nothing else (rule 0:
14//!   covers a crash between a write and its record);
15//! - the record changed, disk didn't → write the head to disk (or, for a
16//!   deletion, remove the file — only if it still holds what was projected);
17//! - disk changed, the record didn't → capture it as a new version;
18//! - both changed → conflict: disk's version becomes the head, marked
19//!   `conflicts_with` the record's, which is written beside it as
20//!   `<stem>__conflict_<short>.md` and recorded, so it follows the agent;
21//! - the first time the record meets an exclusive directory, its files are
22//!   adopted together as the baseline.
23//!
24//! Safety rules, each from a data-loss review of the first version:
25//! - A file that can't be read is left alone — never taken for deleted, and
26//!   never overwritten. A folder that can't be listed skips the pass. A
27//!   folder that doesn't exist, or holds none of the files projected into
28//!   it, has its projections forgotten first and is refilled — never
29//!   tombstoned, even if the refill stops part-way.
30//! - MEMORY.md is reconciled first, and every file is decided against a
31//!   fresh read of the record, never a snapshot from the start of the pass.
32//! - MEMORY.md is edited (to index a conflict file) only when disk, the
33//!   head and this directory's projection all agree on it; otherwise a
34//!   later pass adds the line.
35//! - A deletion re-reads the file and removes it only if it still holds the
36//!   projected content, and only in a pass that finished everything else.
37//! - The pass has a time budget; exclusivity that can't be proven in time
38//!   counts as shared.
39//! - The folder is the one the CLI really uses: keyed by the repository's
40//!   main checkout, not the working directory
41//!   ([`crate::backend::claude_layout::memory_project_root`]). A folder the
42//!   spawn or a settings file may redirect is left alone.
43//! - Each file is read again when its turn comes, and again right before
44//!   it is overwritten; a provider write in between wins, and is captured
45//!   next pass.
46//! - One pass per agent at a time, across AgentMux processes (a lease in
47//!   the global store); a second one skips.
48
49use std::collections::BTreeMap;
50use std::path::Path;
51use std::time::{Duration, Instant};
52
53use crate::backend::memory_dir_claims::{self as claims, Exclusivity};
54use crate::backend::memory_record::{self as record, AppendOutcome, Heads, NewVersion};
55use crate::backend::storage::error::StoreError;
56use crate::backend::storage::filestore::FileStore;
57use crate::backend::storage::store::Store;
58
59/// The whole pass, before the spawn proceeds without it.
60pub(crate) const RECONCILE_BUDGET: Duration = Duration::from_secs(1);
61const INDEX_FILE: &str = "MEMORY.md";
62const CONFLICT_MARK: &str = "__conflict_";
63
64/// What a spawn's memory directory is.
65pub(crate) struct SpawnMemory<'a> {
66    pub uid: &'a str,
67    pub provider: &'a str,
68    pub config_dir: Option<&'a str>,
69    pub cwd: &'a str,
70    /// The spawn itself may point the CLI's memory somewhere else
71    /// ([`spawn_overrides_memory_dir`]).
72    pub overridden: bool,
73    /// Where the agent's earlier per-channel history is imported from
74    /// ([`crate::backend::memory_history_import`]); `None`: every channel
75    /// store on this machine.
76    pub history_stores: Option<&'a [std::path::PathBuf]>,
77}
78
79/// Whether a spawn's environment or arguments may move the CLI's memory
80/// folder away from `projects/<root>/memory`: an override variable, or a
81/// settings file passed on the command line (it could set
82/// `autoMemoryDirectory`).
83pub(crate) fn spawn_overrides_memory_dir(env: &std::collections::HashMap<String, String>, args: &[String]) -> bool {
84    // CLAUDE_CODE_PROJECT_DIR_NAME replaces the `projects/<name>` folder
85    // name for every working directory (the CLI's `wyo`).
86    const VARS: [&str; 3] = ["CLAUDE_COWORK_MEMORY_PATH_OVERRIDE", "CLAUDE_CODE_REMOTE_MEMORY_DIR", "CLAUDE_CODE_PROJECT_DIR_NAME"];
87    VARS.iter().any(|v| env.get(*v).is_some_and(|x| !x.is_empty()) || std::env::var_os(v).is_some_and(|x| !x.is_empty()))
88        || args.iter().any(|a| {
89            ["--settings", "--managed-settings"].iter().any(|f| a == f || a.starts_with(&format!("{f}=")))
90        })
91}
92
93/// Whether a settings file the CLI reads mentions `autoMemoryDirectory`,
94/// which moves its memory folder. Any mention counts, parsed or not.
95/// Remote managed settings can't be seen from here.
96fn settings_override_memory_dir(config_dir: &Path, root: &Path) -> bool {
97    let mut files = vec![
98        config_dir.join("settings.json"),
99        root.join(".claude").join("settings.json"),
100        root.join(".claude").join("settings.local.json"),
101    ];
102    if cfg!(target_os = "macos") {
103        files.push("/Library/Application Support/ClaudeCode/managed-settings.json".into());
104    } else if cfg!(windows) {
105        files.push(r"C:\Program Files\ClaudeCode\managed-settings.json".into());
106        files.push(r"C:\ProgramData\ClaudeCode\managed-settings.json".into());
107    } else {
108        files.push("/etc/claude-code/managed-settings.json".into());
109    }
110    // Drop-in fragments beside each managed-settings file.
111    let drop_ins: Vec<std::path::PathBuf> = files
112        .iter()
113        .filter(|f| f.file_name().is_some_and(|n| n == "managed-settings.json"))
114        .filter_map(|f| std::fs::read_dir(f.with_file_name("managed-settings.d")).ok())
115        .flat_map(|d| d.flatten().map(|e| e.path()))
116        .collect();
117    files.extend(drop_ins);
118    files.iter().any(|f| std::fs::read(f).is_ok_and(|b| b.windows(19).any(|w| w == b"autoMemoryDirectory")))
119}
120
121/// A lease on `uid`'s reconcile, across every AgentMux process on this
122/// machine: two passes for one agent at once — two spawns of it, in one
123/// process or two — each act on a view of the folder the other is
124/// changing. The second one skips; its spawn goes ahead.
125const PASS_LEASE_FILE: &str = "lease.json";
126
127#[derive(serde::Serialize, serde::Deserialize, Default)]
128struct PassLease {
129    #[serde(default)]
130    owner: String,
131    #[serde(default)]
132    until_ms: i64,
133}
134
135fn pass_zone(uid: &str) -> String {
136    format!("memory-pass:{}", &record::sha256_hex(uid.as_bytes())[..32])
137}
138
139/// Take the lease until `until`; `None` while another pass holds it.
140pub(crate) fn take_pass_lease(fs: &FileStore, uid: &str, until: Instant) -> Result<Option<String>, StoreError> {
141    let now = agentmux_common::time::now_ms();
142    // Held past the budget by a margin, in case the pass overruns it
143    // mid-file; a crashed pass frees it once that passes.
144    let until_ms = now + until.saturating_duration_since(Instant::now()).as_millis() as i64 + 5_000;
145    let owner = uuid::Uuid::new_v4().simple().to_string();
146    fs.zone_txn(&pass_zone(uid), |z| {
147        let held = z.read(PASS_LEASE_FILE)?.and_then(|b| serde_json::from_slice::<PassLease>(&b).ok());
148        // No pass's lease legitimately runs more than a minute ahead; one
149        // further out was taken while the clock was ahead, and would
150        // otherwise block every pass until the clock caught up.
151        if held.is_some_and(|l| l.until_ms > now && l.until_ms <= now + 60_000) {
152            return Ok(None);
153        }
154        let lease = PassLease { owner: owner.clone(), until_ms };
155        z.put(PASS_LEASE_FILE, &serde_json::to_vec(&lease).map_err(|e| StoreError::Other(e.to_string()))?)?;
156        Ok(Some(owner.clone()))
157    })
158}
159
160pub(crate) fn release_pass_lease(fs: &FileStore, uid: &str, owner: &str) {
161    let released = fs.zone_txn(&pass_zone(uid), |z| {
162        let held = z.read(PASS_LEASE_FILE)?.and_then(|b| serde_json::from_slice::<PassLease>(&b).ok());
163        if held.is_some_and(|l| l.owner == owner) {
164            z.put(PASS_LEASE_FILE, &serde_json::to_vec(&PassLease::default()).map_err(|e| StoreError::Other(e.to_string()))?)?;
165        }
166        Ok(())
167    });
168    if let Err(e) = released {
169        tracing::warn!(uid, error = %e, "memory reconcile: lease not released; it expires on its own");
170    }
171}
172
173#[derive(Debug, Default, PartialEq, Eq)]
174pub(crate) struct Report {
175    pub skipped: Option<&'static str>,
176    pub adopted: usize,
177    /// Versions imported from the per-channel history, the first time.
178    pub imported: usize,
179    /// Files held for the adoption list at first sighting: another agent's
180    /// record already holds their content.
181    pub held: usize,
182    pub written: usize,
183    pub captured: usize,
184    pub conflicts: usize,
185    pub deleted: usize,
186    pub unreadable: usize,
187    /// The budget ran out; the rest waits for the next spawn.
188    pub deferred: bool,
189}
190
191/// The pass's time budget. `files_left` lets tests stop a pass part-way.
192struct Budget {
193    deadline: Instant,
194    files_left: Option<usize>,
195}
196
197impl Budget {
198    fn spent(&mut self) -> bool {
199        if Instant::now() > self.deadline {
200            return true;
201        }
202        match &mut self.files_left {
203            Some(0) => true,
204            Some(n) => {
205                *n -= 1;
206                false
207            }
208            None => false,
209        }
210    }
211}
212
213/// Reconcile before a spawn. Never fails the spawn: every error is logged
214/// and reported as skipped.
215pub(crate) fn reconcile_before_spawn(fs: &FileStore, mstore: &Store, m: &SpawnMemory<'_>, budget: Duration) -> Report {
216    run(fs, mstore, m, Budget { deadline: Instant::now() + budget, files_left: None })
217}
218
219fn run(fs: &FileStore, mstore: &Store, m: &SpawnMemory<'_>, mut budget: Budget) -> Report {
220    let mut report = Report::default();
221    if let Err(e) = reconcile_inner(fs, mstore, m, &mut budget, &mut report) {
222        tracing::warn!(uid = m.uid, error = %e, "memory reconcile stopped");
223        report.skipped = Some("error");
224    }
225    report
226}
227
228/// A memory file as read from disk.
229enum OnDisk {
230    Body(Vec<u8>),
231    /// Present but unreadable (permissions, over the size cap): left alone.
232    Unreadable,
233}
234
235/// One memory file as it is on disk now; `None` when it doesn't exist.
236fn read_one(dir: &Path, name: &str) -> Option<OnDisk> {
237    let path = dir.join(name);
238    match std::fs::metadata(&path) {
239        Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
240        Ok(meta) if !meta.is_file() => Some(OnDisk::Unreadable),
241        Ok(meta) if meta.len() as usize > record::MAX_BODY_BYTES => Some(OnDisk::Unreadable),
242        Ok(_) => Some(std::fs::read(&path).map(OnDisk::Body).unwrap_or(OnDisk::Unreadable)),
243        Err(_) => Some(OnDisk::Unreadable),
244    }
245}
246
247/// Whether `name` still holds content `sha` (`None`: still absent), read
248/// again immediately before overwriting it — the provider may have
249/// written it since this pass decided. What it wrote is captured next pass.
250fn disk_still_holds(dir: &Path, name: &str, sha: Option<&str>) -> bool {
251    match read_one(dir, name) {
252        None => sha.is_none(),
253        Some(OnDisk::Body(b)) => sha == Some(record::sha256_hex(&b).as_str()),
254        Some(OnDisk::Unreadable) => false,
255    }
256}
257
258/// The folder's memory files, or `None` when the folder doesn't exist. Any
259/// other listing error fails the pass rather than read as "no files".
260fn read_disk(dir: &Path) -> Result<Option<BTreeMap<String, OnDisk>>, StoreError> {
261    let entries = match std::fs::read_dir(dir) {
262        Ok(e) => e,
263        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
264        Err(e) => return Err(StoreError::Other(format!("memory reconcile: list {}: {e}", dir.display()))),
265    };
266    let mut out = BTreeMap::new();
267    for entry in entries {
268        let entry = entry.map_err(|e| StoreError::Other(format!("memory reconcile: list: {e}")))?;
269        let name = entry.file_name().to_string_lossy().into_owned();
270        if crate::server::native_memory_handlers::validate_filename(&name).is_err() {
271            continue;
272        }
273        let path = entry.path();
274        let body = match std::fs::metadata(&path) {
275            Ok(meta) if !meta.is_file() => continue,
276            Ok(meta) if meta.len() as usize > record::MAX_BODY_BYTES => OnDisk::Unreadable,
277            Ok(_) => std::fs::read(&path).map(OnDisk::Body).unwrap_or(OnDisk::Unreadable),
278            Err(_) => OnDisk::Unreadable,
279        };
280        out.insert(name, body);
281    }
282    Ok(Some(out))
283}
284
285fn reconcile_inner(
286    fs: &FileStore,
287    mstore: &Store,
288    m: &SpawnMemory<'_>,
289    budget: &mut Budget,
290    report: &mut Report,
291) -> Result<(), StoreError> {
292    if m.provider != "claude" {
293        report.skipped = Some("provider");
294        return Ok(());
295    }
296    if m.cwd.trim().is_empty() {
297        report.skipped = Some("no working directory");
298        return Ok(());
299    }
300    let dir = crate::server::native_memory_handlers::memory_dir_for_cwd(m.config_dir.unwrap_or(""), m.cwd);
301    // `dir` is `<config>/projects/<name>/memory`.
302    let config_dir = dir.parent().and_then(Path::parent).and_then(Path::parent).unwrap_or(&dir);
303    let root = crate::backend::claude_layout::memory_project_root(&crate::backend::base::expand_home_dir_safe(m.cwd));
304    if m.overridden || settings_override_memory_dir(config_dir, &root) {
305        tracing::info!(uid = m.uid, "memory reconcile: the CLI's memory folder may be overridden; left as is");
306        report.skipped = Some("memory folder overridden");
307        return Ok(());
308    }
309    if let Exclusivity::Shared(reason) = claims::check_and_claim(fs, mstore, m.uid, &dir, m.cwd, budget.deadline)? {
310        tracing::info!(uid = m.uid, dir = %dir.display(), ?reason, "memory reconcile: directory not exclusive; left as is");
311        report.skipped = Some("shared directory");
312        return Ok(());
313    }
314    let Some(lease) = take_pass_lease(fs, m.uid, budget.deadline)? else {
315        tracing::info!(uid = m.uid, "memory reconcile: another pass for this agent is running; skipped");
316        report.skipped = Some("another pass running");
317        return Ok(());
318    };
319    let result = reconcile_dir(fs, m.uid, &dir, m.history_stores, budget, report);
320    release_pass_lease(fs, m.uid, &lease);
321    result
322}
323
324fn reconcile_dir(
325    fs: &FileStore,
326    uid: &str,
327    dir: &Path,
328    history_stores: Option<&[std::path::PathBuf]>,
329    budget: &mut Budget,
330    report: &mut Report,
331) -> Result<(), StoreError> {
332    let dir_id = claims::dir_id(dir);
333    let disk = read_disk(dir)?;
334    let dir_missing = disk.is_none();
335    let disk = disk.unwrap_or_default();
336    report.unreadable = disk.values().filter(|d| matches!(d, OnDisk::Unreadable)).count();
337
338    // The agent's earlier history, once, now that its folder is proven its
339    // own. History only; a failure leaves it for a later pass.
340    let disk_sha = |name: &str| match disk.get(name) {
341        Some(OnDisk::Body(b)) => Some(record::sha256_hex(b)),
342        _ => None,
343    };
344    match crate::backend::memory_history_import::import_once(fs, uid, history_stores, disk_sha, budget.deadline) {
345        Ok(Some(n)) => report.imported = n,
346        Ok(None) => {}
347        Err(e) => tracing::warn!(uid, error = %e, "memory reconcile: history import failed; retried next pass"),
348    }
349
350    // A folder that doesn't exist, or exists but holds none of the files
351    // projected into it, no longer holds what was written there: forget
352    // those projections first (in the record, so a refill cut short by the
353    // budget or a crash still reads the rest as "never projected" next
354    // time) and refill it, rather than read the absence as deletions.
355    let heads_now = record::heads(fs, uid)?;
356    let projected_here = heads_now.projected.get(&dir_id).is_some_and(|p| !p.is_empty());
357    let none_left = !disk.keys().any(|n| heads_now.projected.get(&dir_id).is_some_and(|p| p.contains_key(n)));
358    if projected_here && (dir_missing || none_left) {
359        tracing::warn!(uid, dir = %dir.display(), "memory folder no longer holds its files; refilling it from the record");
360        record::reset_projections(fs, uid, &dir_id)?;
361    }
362
363    if record::heads(fs, uid)?.files.is_empty() {
364        adopt_baseline(fs, uid, &dir_id, &disk, budget, report)?;
365        return Ok(());
366    }
367
368    // Only names the disk scan also recognises: a name it skips would be
369    // written, then read back as absent and tombstoned.
370    let mut names: Vec<String> = record::heads(fs, uid)?
371        .files
372        .keys()
373        .filter(|n| crate::server::native_memory_handlers::validate_filename(n).is_ok())
374        .cloned()
375        .collect();
376    names.extend(disk.keys().cloned());
377    names.sort_by(|a, b| (a != INDEX_FILE, a).cmp(&(b != INDEX_FILE, b)));
378    names.dedup();
379
380    let mut deletions: Vec<Deletion> = Vec::new();
381    let mut shas = ShaCache::default();
382    for name in &names {
383        if budget.spent() {
384            report.deferred = true;
385            break;
386        }
387        reconcile_file(fs, uid, dir, &dir_id, name, report, &mut deletions, &mut shas, true)?;
388    }
389    if report.deferred {
390        return Ok(());
391    }
392    for d in deletions {
393        delete_if_unchanged(fs, uid, dir, &dir_id, &d, report)?;
394    }
395    index_conflict_files(fs, uid, dir, &dir_id)?;
396    Ok(())
397}
398
399/// The first time the record meets this agent's own exclusive directory:
400/// every file in it, index and topics together, becomes the baseline.
401fn adopt_baseline(
402    fs: &FileStore,
403    uid: &str,
404    dir_id: &str,
405    disk: &BTreeMap<String, OnDisk>,
406    budget: &mut Budget,
407    report: &mut Report,
408) -> Result<(), StoreError> {
409    // Content another agent's record already holds isn't this agent's to
410    // adopt: held for the adoption list (spec §2.1.2). All of it first, in
411    // one transaction, so a baseline cut short by the budget — whose rest
412    // the next pass captures as new files — never takes it either.
413    let shas: Vec<(String, String)> = disk
414        .iter()
415        .filter_map(|(name, d)| match d {
416            OnDisk::Body(b) => Some((name.clone(), record::sha256_hex(b))),
417            OnDisk::Unreadable => None,
418        })
419        .collect();
420    let foreign = record::held_by_other_records(fs, uid, &shas.iter().map(|(_, s)| s.clone()).collect::<Vec<_>>())?;
421    let held: Vec<(String, String)> = shas.into_iter().filter(|(_, sha)| foreign.contains(sha)).collect();
422    record::hold_for_adoption(fs, uid, dir_id, &held)?;
423    report.held = held.len();
424    for (name, body) in disk {
425        let OnDisk::Body(body) = body else { continue };
426        if held.iter().any(|(n, _)| n == name) {
427            continue;
428        }
429        if budget.spent() {
430            // The rest are captured, as new files, by the next pass.
431            report.deferred = true;
432            return Ok(());
433        }
434        let outcome = record::append_version(
435            fs,
436            uid,
437            NewVersion {
438                file: name,
439                body: Some(body),
440                expected_parent: None,
441                merged_parent: None,
442                conflicts_with: None,
443                source: "adopted",
444                source_detail: "first sighting, own spawn dir",
445                project_to: Some(dir_id),
446            },
447        )?;
448        if matches!(outcome, AppendOutcome::Appended(_)) {
449            report.adopted += 1;
450        }
451    }
452    Ok(())
453}
454
455/// Versions' content hashes for one pass, read from the log once — not
456/// once per file, which made a pass quadratic in the log's length. A
457/// version appended during the pass is found by reading it again.
458#[derive(Default)]
459pub(crate) struct ShaCache {
460    shas: std::collections::HashMap<String, Option<String>>,
461}
462
463impl ShaCache {
464    fn sha_of(&mut self, fs: &FileStore, uid: &str, version: &str) -> Result<Option<String>, StoreError> {
465        if let Some(sha) = self.shas.get(version) {
466            return Ok(sha.clone());
467        }
468        self.shas = record::version_shas(fs, uid)?;
469        Ok(self.shas.get(version).cloned().flatten())
470    }
471}
472
473fn held_here<'h>(heads: &'h Heads, dir_id: &str, name: &str) -> Option<&'h str> {
474    heads.held.get(dir_id).and_then(|m| m.get(name)).map(String::as_str)
475}
476
477fn projected_version(heads: &Heads, dir_id: &str, name: &str) -> Option<String> {
478    heads.projected.get(dir_id).and_then(|p| p.get(name)).cloned()
479}
480
481/// A queued deletion: remove `name` if it still holds `expected_sha`.
482struct Deletion {
483    name: String,
484    tombstone: String,
485    projected: Option<String>,
486    expected_sha: String,
487}
488
489#[allow(clippy::too_many_arguments)]
490fn reconcile_file(
491    fs: &FileStore,
492    uid: &str,
493    dir: &Path,
494    dir_id: &str,
495    name: &str,
496    report: &mut Report,
497    deletions: &mut Vec<Deletion>,
498    shas: &mut ShaCache,
499    may_retry: bool,
500) -> Result<(), StoreError> {
501    // Always decide against the disk and the record as they are now, never
502    // a listing from the start of the pass: an earlier file in this pass may
503    // have changed the record, and the provider may have written the file.
504    let on_disk = match read_one(dir, name) {
505        Some(OnDisk::Unreadable) => return Ok(()),
506        Some(OnDisk::Body(b)) => Some(b),
507        None => None,
508    };
509    let on_disk = on_disk.as_deref();
510    let heads = record::heads(fs, uid)?;
511    // Held for the adoption list: left alone while it still holds that.
512    if on_disk.is_some_and(|b| held_here(&heads, dir_id, name) == Some(record::sha256_hex(b).as_str())) {
513        return Ok(());
514    }
515    let head = heads.files.get(name).cloned();
516    let head_sha = head.as_ref().and_then(|h| h.sha256.clone());
517    let disk_sha = on_disk.map(record::sha256_hex);
518    let projected = projected_version(&heads, dir_id, name);
519
520    // Rule 0: disk already holds the head (or both are absent).
521    if disk_sha == head_sha {
522        if let Some(h) = &head {
523            if projected.as_deref() != Some(h.version.as_str()) {
524                record::record_projected(fs, uid, dir_id, name, &h.version, projected_version(&heads, dir_id, name).as_deref())?;
525            }
526        }
527        return Ok(());
528    }
529
530    let projected_sha = match &projected {
531        Some(p) => shas.sha_of(fs, uid, p)?,
532        None => None,
533    };
534    let disk_changed = match &projected {
535        Some(_) => disk_sha != projected_sha,
536        // Never projected here: anything on disk is news to the record.
537        None => disk_sha.is_some(),
538    };
539    let record_changed = match (&projected, &head) {
540        (Some(p), Some(h)) => &h.version != p,
541        (None, Some(h)) => h.sha256.is_some(),
542        (_, None) => false,
543    };
544
545    match (disk_changed, record_changed) {
546        (false, true) => {
547            let h = head.expect("record_changed implies a head");
548            match &h.sha256 {
549                Some(sha) => {
550                    let body = record::body(fs, uid, sha)?
551                        .ok_or_else(|| StoreError::Other(format!("memory record: body {sha} missing")))?;
552                    if !disk_still_holds(dir, name, disk_sha.as_deref()) {
553                        return Ok(());
554                    }
555                    write_atomic(dir, name, &body)?;
556                    record::record_projected(fs, uid, dir_id, name, &h.version, projected_version(&heads, dir_id, name).as_deref())?;
557                    report.written += 1;
558                }
559                // A deletion elsewhere; disk still holds what was projected.
560                None => {
561                    if let (Some(expected_sha), Some(_)) = (projected_sha, on_disk) {
562                        deletions.push(Deletion {
563                            name: name.to_string(),
564                            tombstone: h.version.clone(),
565                            projected: projected.clone(),
566                            expected_sha,
567                        });
568                    }
569                }
570            }
571        }
572        (true, false) => {
573            // A deletion is captured only against a real projection here.
574            if on_disk.is_none() && projected.is_none() {
575                return Ok(());
576            }
577            let outcome = record::append_version(
578                fs,
579                uid,
580                NewVersion {
581                    file: name,
582                    body: on_disk,
583                    expected_parent: head.as_ref().map(|h| h.version.as_str()),
584                    merged_parent: None,
585                    conflicts_with: None,
586                    source: "provider",
587                    source_detail: "",
588                    project_to: Some(dir_id),
589                },
590            )?;
591            match outcome {
592                AppendOutcome::Appended(_) => report.captured += 1,
593                AppendOutcome::StaleParent { .. } if may_retry => {
594                    return reconcile_file(fs, uid, dir, dir_id, name, report, deletions, shas, false);
595                }
596                _ => {}
597            }
598        }
599        (true, true) => {
600            let h = head.expect("record_changed implies a head");
601            let Some(disk_body) = on_disk else {
602                // Deleted here, changed elsewhere: keep the record's version.
603                if let Some(sha) = &h.sha256 {
604                    if let Some(body) = record::body(fs, uid, sha)? {
605                        if !disk_still_holds(dir, name, None) {
606                            return Ok(());
607                        }
608                        write_atomic(dir, name, &body)?;
609                        record::record_projected(fs, uid, dir_id, name, &h.version, projected_version(&heads, dir_id, name).as_deref())?;
610                        report.written += 1;
611                    }
612                }
613                return Ok(());
614            };
615            // The other side is written and recorded FIRST: if anything
616            // fails before the winner is recorded, the conflict is simply
617            // raised again next pass; the reverse order could lose it.
618            if let Some(sha) = &h.sha256 {
619                if let Some(theirs) = record::body(fs, uid, sha)? {
620                    keep_conflict_copy(fs, uid, dir, dir_id, &conflict_file_name(name, &h.version), &theirs)?;
621                }
622            }
623            let outcome = record::append_version(
624                fs,
625                uid,
626                NewVersion {
627                    file: name,
628                    body: Some(disk_body),
629                    expected_parent: Some(&h.version),
630                    merged_parent: None,
631                    conflicts_with: Some(&h.version),
632                    source: "provider",
633                    source_detail: "conflict",
634                    project_to: Some(dir_id),
635                },
636            )?;
637            match outcome {
638                AppendOutcome::Appended(_) => report.conflicts += 1,
639                AppendOutcome::StaleParent { .. } if may_retry => {
640                    return reconcile_file(fs, uid, dir, dir_id, name, report, deletions, shas, false);
641                }
642                _ => {}
643            }
644        }
645        (false, false) => {}
646    }
647    Ok(())
648}
649
650/// Write the other version of a conflicted file beside it and record it, so
651/// it follows the agent to its other folders (and a deletion of it, once
652/// merged, does too).
653fn keep_conflict_copy(fs: &FileStore, uid: &str, dir: &Path, dir_id: &str, name: &str, body: &[u8]) -> Result<(), StoreError> {
654    if dir.join(name).exists() {
655        return Ok(());
656    }
657    write_atomic(dir, name, body)?;
658    record::append_version(
659        fs,
660        uid,
661        NewVersion {
662            file: name,
663            body: Some(body),
664            expected_parent: None,
665            merged_parent: None,
666            conflicts_with: None,
667            source: "agentmux",
668            source_detail: "conflict copy",
669            project_to: Some(dir_id),
670        },
671    )?;
672    Ok(())
673}
674
675/// Remove a file the record deleted, if it still holds what was projected.
676fn delete_if_unchanged(fs: &FileStore, uid: &str, dir: &Path, dir_id: &str, d: &Deletion, report: &mut Report) -> Result<(), StoreError> {
677    let heads = record::heads(fs, uid)?;
678    let still_tombstone = heads.files.get(&d.name).is_some_and(|h| h.version == d.tombstone && h.sha256.is_none());
679    let still_projected = projected_version(&heads, dir_id, &d.name) == d.projected;
680    if !still_tombstone || !still_projected {
681        return Ok(());
682    }
683    let path = dir.join(&d.name);
684    let Ok(current) = std::fs::read(&path) else { return Ok(()) };
685    if record::sha256_hex(&current) != d.expected_sha {
686        return Ok(());
687    }
688    std::fs::remove_file(&path).map_err(|e| StoreError::Other(format!("memory reconcile: remove: {e}")))?;
689    record::record_projected(fs, uid, dir_id, &d.name, &d.tombstone, d.projected.as_deref())?;
690    report.deleted += 1;
691    Ok(())
692}
693
694/// Index every conflict copy in MEMORY.md — Claude loads only the index, so
695/// an unindexed file would never be seen. Only when disk, the head and this
696/// directory's projection all agree on MEMORY.md; otherwise a later pass.
697fn index_conflict_files(fs: &FileStore, uid: &str, dir: &Path, dir_id: &str) -> Result<(), StoreError> {
698    let heads = record::heads(fs, uid)?;
699    // Each conflict file gets its line once: one someone removed stays gone.
700    let conflict_files: Vec<&String> = heads
701        .live()
702        .map(|(n, _)| n)
703        .filter(|n| n.contains(CONFLICT_MARK) && !heads.indexed_conflicts.contains(*n))
704        .collect();
705    if conflict_files.is_empty() {
706        return Ok(());
707    }
708    let head = heads.files.get(INDEX_FILE).filter(|h| h.sha256.is_some());
709    let projected = projected_version(&heads, dir_id, INDEX_FILE);
710    let path = dir.join(INDEX_FILE);
711    let on_disk = match std::fs::read(&path) {
712        Ok(b) => Some(b),
713        Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
714        Err(_) => return Ok(()),
715    };
716    let clean = match (head, &on_disk) {
717        (Some(h), Some(b)) => projected.as_deref() == Some(h.version.as_str()) && h.sha256.as_deref() == Some(&record::sha256_hex(b)),
718        (None, None) => true,
719        _ => false,
720    };
721    if !clean {
722        return Ok(());
723    }
724    // Appended to the bytes as they are: an index that isn't UTF-8 keeps
725    // every byte it had.
726    let disk_sha = on_disk.as_deref().map(record::sha256_hex);
727    let mut next = on_disk.unwrap_or_default();
728    let before = next.clone();
729    let mut already: Vec<String> = Vec::new();
730    for name in &conflict_files {
731        if next.windows(name.len()).any(|w| w == name.as_bytes()) {
732            already.push(name.to_string());
733            continue;
734        }
735        if !next.is_empty() && !next.ends_with(b"\n") {
736            next.push(b'\n');
737        }
738        let of = name.split(CONFLICT_MARK).next().unwrap_or(name);
739        next.extend_from_slice(
740            format!("- [{of}.md — another copy]({name}) — AgentMux kept both versions of {of}.md; merge them and delete this file\n")
741                .as_bytes(),
742        );
743    }
744    if next == before {
745        record::mark_conflicts_indexed(fs, uid, &already)?;
746        return Ok(());
747    }
748    let outcome = record::append_version(
749        fs,
750        uid,
751        NewVersion {
752            file: INDEX_FILE,
753            body: Some(&next),
754            expected_parent: head.map(|h| h.version.as_str()),
755            merged_parent: None,
756            conflicts_with: None,
757            source: "agentmux",
758            source_detail: "conflict index line",
759            project_to: Some(dir_id),
760        },
761    )?;
762    // Written to disk only once recorded against the head it was based on.
763    if matches!(outcome, AppendOutcome::Appended(_)) && disk_still_holds(dir, INDEX_FILE, disk_sha.as_deref()) {
764        write_atomic(dir, INDEX_FILE, &next)?;
765        let names: Vec<String> = conflict_files.iter().map(|n| n.to_string()).collect();
766        record::mark_conflicts_indexed(fs, uid, &names)?;
767    }
768    Ok(())
769}
770
771/// How long a memory file must go unchanged before a capture while the
772/// agent runs takes it.
773pub(crate) const CAPTURE_SETTLE: Duration = Duration::from_secs(2);
774
775/// A file the provider changed in `dir` since it was last projected there,
776/// with the head it will be recorded on.
777struct PendingCapture {
778    name: String,
779    body: Vec<u8>,
780    head: String,
781}
782
783/// What [`capture_while_running`] would record now: files whose content
784/// differs from what was projected here, while the record hasn't moved on
785/// since (the head is still that projection). Files the record changed
786/// elsewhere meanwhile are left for the next spawn's reconcile, which
787/// raises the conflict; deletions too, with its missing-folder guards.
788fn pending_captures(fs: &FileStore, uid: &str, dir: &Path, dir_id: &str, settle: Duration) -> Result<Vec<PendingCapture>, StoreError> {
789    let heads = record::heads(fs, uid)?;
790    let Some(projected) = heads.projected.get(dir_id) else { return Ok(Vec::new()) };
791    let Some(disk) = read_disk(dir)? else { return Ok(Vec::new()) };
792    let mut out = Vec::new();
793    for (name, on_disk) in disk {
794        let OnDisk::Body(body) = on_disk else { continue };
795        // Still being written (the watcher fires as the write starts): a
796        // torn body would become the head, and the next spawn elsewhere
797        // would project it. The next event or sweep takes it once settled.
798        let modified = std::fs::metadata(dir.join(&name)).and_then(|m| m.modified());
799        if modified.map_or(true, |t| t.elapsed().map_or(true, |age| age < settle)) {
800            continue;
801        }
802        let sha = record::sha256_hex(&body);
803        if held_here(&heads, dir_id, &name) == Some(sha.as_str()) {
804            continue;
805        }
806        let head = heads.files.get(&name);
807        if head.is_some_and(|h| h.sha256.as_deref() == Some(sha.as_str())) {
808            continue;
809        }
810        let projected_here = projected.get(&name);
811        match (head, projected_here) {
812            // The provider changed a file this folder holds the head of.
813            (Some(h), Some(p)) if &h.version == p => out.push(PendingCapture { name, body, head: h.version.clone() }),
814            // A new file the record has never had.
815            (None, None) => out.push(PendingCapture { name, body, head: String::new() }),
816            _ => {}
817        }
818    }
819    Ok(out)
820}
821
822/// Record what the provider wrote to `dir` while the agent runs — the drift
823/// detector's hook (SPEC_MEMORY_FOLLOWS_THE_AGENT_2026_09_24.md §2.1.3).
824/// Capture only: nothing is written to the folder. Only a folder a spawn
825/// has already reconciled (it holds projections), whose claim `uid` still
826/// holds alone, and only files unchanged for `settle` ([`CAPTURE_SETTLE`]).
827/// Returns how many versions were recorded.
828pub(crate) fn capture_while_running(fs: &FileStore, uid: &str, dir: &Path, settle: Duration) -> Result<usize, StoreError> {
829    let dir_id = claims::dir_id(dir);
830    // Cheap and read-only first: most sweeps find nothing to record.
831    if pending_captures(fs, uid, dir, &dir_id, settle)?.is_empty() {
832        return Ok(0);
833    }
834    if !claims::held_exclusively(fs, uid, dir)? {
835        return Ok(0);
836    }
837    let Some(lease) = take_pass_lease(fs, uid, Instant::now() + RECONCILE_BUDGET)? else {
838        // A spawn's reconcile is running; it records this itself.
839        return Ok(0);
840    };
841    let result = (|| {
842        let mut recorded = 0;
843        // Decided again under the lease, against a fresh read.
844        for p in pending_captures(fs, uid, dir, &dir_id, settle)? {
845            let outcome = record::append_version(
846                fs,
847                uid,
848                NewVersion {
849                    file: &p.name,
850                    body: Some(&p.body),
851                    expected_parent: (!p.head.is_empty()).then_some(p.head.as_str()),
852                    merged_parent: None,
853                    conflicts_with: None,
854                    source: "provider",
855                    source_detail: "while running",
856                    project_to: Some(&dir_id),
857                },
858            )?;
859            if matches!(outcome, AppendOutcome::Appended(_)) {
860                recorded += 1;
861            }
862        }
863        Ok(recorded)
864    })();
865    release_pass_lease(fs, uid, &lease);
866    result
867}
868
869/// An AgentMux write to an agent's memory — MemoryWrite, the Memory editor,
870/// a revert, a bundle import — recorded before the file is written
871/// (SPEC_MEMORY_FOLLOWS_THE_AGENT_2026_09_24.md §2.1.1), so the next spawn
872/// doesn't take it for the provider's, and it follows the agent at once.
873///
874/// Only into the agent's own folder (a claim it holds alone), on the version
875/// that folder holds: if the record changed the file elsewhere since, nothing
876/// is recorded and the next spawn's reconcile keeps both. Never fails the
877/// write: an error is logged, and a later capture records the file.
878pub(crate) fn record_agentmux_write(uid: &str, dir: &Path, file: &str, body: &[u8], source: &str, source_detail: &str) {
879    let Some(fs) = crate::backend::agent_session::global_transcript_store() else { return };
880    if let Err(e) = record_agentmux_write_in(fs, uid, dir, file, body, source, source_detail) {
881        tracing::warn!(uid, file, error = %e, "memory record: AgentMux write not recorded; a later capture records it");
882    }
883}
884
885/// [`record_agentmux_write`] against `fs`. Returns whether it was recorded.
886pub(crate) fn record_agentmux_write_in(
887    fs: &FileStore,
888    uid: &str,
889    dir: &Path,
890    file: &str,
891    body: &[u8],
892    source: &str,
893    source_detail: &str,
894) -> Result<bool, StoreError> {
895    if record::validate_file(file).is_err() || !claims::held_exclusively(fs, uid, dir)? {
896        return Ok(false);
897    }
898    let dir_id = claims::dir_id(dir);
899    let heads = record::heads(fs, uid)?;
900    let projected = projected_version(&heads, &dir_id, file);
901    let head = heads.files.get(file).map(|h| h.version.clone());
902    // What this write replaces here: what this folder was last given. A
903    // file the record has but never projected here is left to reconcile.
904    let expected = match (&projected, &head) {
905        (Some(p), _) => Some(p.clone()),
906        (None, None) => None,
907        (None, Some(_)) => return Ok(false),
908    };
909    let outcome = record::append_version(
910        fs,
911        uid,
912        NewVersion {
913            file,
914            body: Some(body),
915            expected_parent: expected.as_deref(),
916            merged_parent: None,
917            conflicts_with: None,
918            source,
919            source_detail,
920            project_to: Some(&dir_id),
921        },
922    )?;
923    Ok(matches!(outcome, AppendOutcome::Appended(_) | AppendOutcome::Unchanged(_)))
924}
925
926/// `<stem>__conflict_<short>.md`, the stem cut so the whole name stays
927/// within `validate_filename`'s 200-character stem.
928pub(crate) fn conflict_file_name(name: &str, version: &str) -> String {
929    let stem = name.strip_suffix(".md").unwrap_or(name);
930    // A conflict on a conflict copy is named after the original, not
931    // stacked (`x__conflict_a__conflict_b.md`). (comment-hygiene: allow)
932    let stem = stem.split(CONFLICT_MARK).next().unwrap_or(stem);
933    let short: String = version.trim_start_matches("v_").chars().take(8).collect();
934    let suffix = format!("{CONFLICT_MARK}{short}");
935    let keep = 200usize.saturating_sub(suffix.len()).min(stem.len());
936    format!("{}{suffix}.md", &stem[..keep])
937}
938
939fn write_atomic(dir: &Path, name: &str, body: &[u8]) -> Result<(), StoreError> {
940    std::fs::create_dir_all(dir).map_err(|e| StoreError::Other(format!("memory reconcile: mkdir: {e}")))?;
941    let tmp = dir.join(format!(".{name}.{}.tmp", uuid::Uuid::new_v4().simple()));
942    std::fs::write(&tmp, body).map_err(|e| StoreError::Other(format!("memory reconcile: write: {e}")))?;
943    std::fs::rename(&tmp, dir.join(name)).map_err(|e| {
944        let _ = std::fs::remove_file(&tmp);
945        StoreError::Other(format!("memory reconcile: rename: {e}"))
946    })
947}
948
949#[cfg(test)]
950mod tests;