1use 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
59pub(crate) const RECONCILE_BUDGET: Duration = Duration::from_secs(1);
61const INDEX_FILE: &str = "MEMORY.md";
62const CONFLICT_MARK: &str = "__conflict_";
63
64pub(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 pub overridden: bool,
73 pub history_stores: Option<&'a [std::path::PathBuf]>,
77}
78
79pub(crate) fn spawn_overrides_memory_dir(env: &std::collections::HashMap<String, String>, args: &[String]) -> bool {
84 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
93fn 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 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
121const 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
139pub(crate) fn take_pass_lease(fs: &FileStore, uid: &str, until: Instant) -> Result<Option<String>, StoreError> {
141 let now = agentmux_common::time::now_ms();
142 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 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 pub imported: usize,
179 pub held: usize,
182 pub written: usize,
183 pub captured: usize,
184 pub conflicts: usize,
185 pub deleted: usize,
186 pub unreadable: usize,
187 pub deferred: bool,
189}
190
191struct 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
213pub(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
228enum OnDisk {
230 Body(Vec<u8>),
231 Unreadable,
233}
234
235fn 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
247fn 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
258fn 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 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 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 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 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
399fn 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 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 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#[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
481struct 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 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 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 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 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 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 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 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 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
650fn 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
675fn 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(¤t) != 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
694fn index_conflict_files(fs: &FileStore, uid: &str, dir: &Path, dir_id: &str) -> Result<(), StoreError> {
698 let heads = record::heads(fs, uid)?;
699 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 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 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
771pub(crate) const CAPTURE_SETTLE: Duration = Duration::from_secs(2);
774
775struct PendingCapture {
778 name: String,
779 body: Vec<u8>,
780 head: String,
781}
782
783fn 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 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 (Some(h), Some(p)) if &h.version == p => out.push(PendingCapture { name, body, head: h.version.clone() }),
814 (None, None) => out.push(PendingCapture { name, body, head: String::new() }),
816 _ => {}
817 }
818 }
819 Ok(out)
820}
821
822pub(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 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 return Ok(0);
840 };
841 let result = (|| {
842 let mut recorded = 0;
843 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
869pub(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
885pub(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 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
926pub(crate) fn conflict_file_name(name: &str, version: &str) -> String {
929 let stem = name.strip_suffix(".md").unwrap_or(name);
930 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;