use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use agent_core::core::session_draft::TurnDraft;
use tokio::sync::{watch, Notify};
use crate::core::session::Session;
enum DraftOp {
Write(TurnDraft),
Remove,
}
#[derive(Default)]
struct Pending {
saves: Vec<Session>,
drafts: Vec<(String, DraftOp)>,
requested: u64,
closed: bool,
}
#[derive(Clone, Copy, Debug)]
struct Progress {
done: u64,
ok: bool,
}
struct Shared {
drafts_dir: PathBuf,
pending: Mutex<Pending>,
wake: Notify,
}
impl Shared {
fn lock(&self) -> std::sync::MutexGuard<'_, Pending> {
self.pending
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
}
pub(crate) struct SnapshotKey {
meta: serde_json::Value,
messages: Vec<crate::SharedMessage>,
}
impl SnapshotKey {
pub(crate) fn of(session: &Session) -> Self {
let mut meta = session.clone();
meta.api_messages = Vec::new();
meta.updated_at = chrono::DateTime::<chrono::Utc>::MIN_UTC;
Self {
meta: serde_json::to_value(&meta).unwrap_or(serde_json::Value::Null),
messages: session.api_messages.clone(),
}
}
}
impl PartialEq for SnapshotKey {
fn eq(&self, other: &Self) -> bool {
self.messages.len() == other.messages.len()
&& self
.messages
.iter()
.zip(&other.messages)
.all(|(a, b)| Arc::ptr_eq(a, b))
&& self.meta == other.meta
}
}
pub(crate) struct Persister {
shared: Arc<Shared>,
progress: watch::Receiver<Progress>,
task: Option<tokio::task::JoinHandle<()>>,
}
impl Persister {
pub(crate) fn new(drafts_dir: PathBuf) -> Self {
let shared = Arc::new(Shared {
drafts_dir,
pending: Mutex::new(Pending::default()),
wake: Notify::new(),
});
let (tx, progress) = watch::channel(Progress { done: 0, ok: true });
let task = tokio::spawn(run(Arc::clone(&shared), tx));
Self {
shared,
progress,
task: Some(task),
}
}
pub(crate) fn save(&self, session: Session) {
{
let mut p = self.shared.lock();
match p.saves.iter_mut().find(|s| s.id == session.id) {
Some(slot) => *slot = session,
None => p.saves.push(session),
}
p.requested += 1;
}
self.shared.wake.notify_one();
}
pub(crate) fn write_draft(&self, id: &str, draft: TurnDraft) {
self.draft(id, DraftOp::Write(draft));
}
pub(crate) fn remove_draft(&self, id: &str) {
self.draft(id, DraftOp::Remove);
}
fn draft(&self, id: &str, op: DraftOp) {
{
let mut p = self.shared.lock();
match p.drafts.iter_mut().find(|(i, _)| i == id) {
Some(slot) => slot.1 = op,
None => p.drafts.push((id.to_string(), op)),
}
p.requested += 1;
}
self.shared.wake.notify_one();
}
pub(crate) async fn flush(&self) -> bool {
let target = self.shared.lock().requested;
let mut rx = self.progress.clone();
loop {
let p = *rx.borrow_and_update();
if p.done >= target {
return p.ok;
}
if rx.changed().await.is_err() {
let p = *rx.borrow();
return p.done >= target && p.ok;
}
}
}
pub(crate) fn saves_ok(&self) -> bool {
self.progress.borrow().ok
}
#[cfg(test)]
pub(crate) fn is_idle(&self) -> bool {
self.progress.borrow().done >= self.shared.lock().requested
}
}
impl Drop for Persister {
fn drop(&mut self) {
self.shared.lock().closed = true;
self.shared.wake.notify_one();
drop(self.task.take());
}
}
async fn run(shared: Arc<Shared>, progress: watch::Sender<Progress>) {
let mut failed: HashSet<String> = HashSet::new();
let mut deferred_removes: HashSet<String> = HashSet::new();
loop {
let batch = {
let mut p = shared.lock();
if p.saves.is_empty() && p.drafts.is_empty() {
if p.closed {
return;
}
None
} else {
Some((
std::mem::take(&mut p.saves),
std::mem::take(&mut p.drafts),
p.requested,
))
}
};
let Some((saves, drafts, covered)) = batch else {
shared.wake.notified().await;
continue;
};
let mut saved: HashSet<String> = HashSet::new();
for session in saves {
match session.save().await {
Ok(()) => {
failed.remove(&session.id);
saved.insert(session.id.clone());
}
Err(e) => {
tracing::error!(session = %session.id, "failed to save session: {e}");
failed.insert(session.id.clone());
}
}
}
let mut ops: Vec<(String, DraftOp)> = Vec::new();
for id in &saved {
if deferred_removes.remove(id) {
ops.push((id.clone(), DraftOp::Remove));
}
}
for (id, op) in drafts {
deferred_removes.remove(&id);
ops.retain(|(i, _)| i != &id);
if matches!(op, DraftOp::Remove) && failed.contains(&id) {
tracing::warn!(
session = %id,
"session save failed; keeping the turn draft until a save succeeds"
);
deferred_removes.insert(id);
continue;
}
ops.push((id, op));
}
for (id, op) in ops {
let dir = shared.drafts_dir.clone();
let label = if matches!(op, DraftOp::Write(_)) { "write" } else { "remove" };
let applied = tokio::task::spawn_blocking(move || match op {
DraftOp::Write(draft) => {
agent_core::core::session_draft::write_turn_draft(&dir, &id, &draft)
}
DraftOp::Remove => agent_core::core::session_draft::remove_turn_draft(&dir, &id),
})
.await
.map_err(std::io::Error::other)
.and_then(|r| r);
if let Err(e) = applied {
tracing::warn!("turn draft {label}: {e}");
}
}
progress.send_replace(Progress {
done: covered,
ok: failed.is_empty(),
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use agent_core::core::session_draft::{read_turn_draft, write_turn_draft};
use serde_json::json;
fn session_with(n: usize) -> Session {
let mut s = Session::new("claude-sonnet-4-5", "low", None);
s.api_messages = (0..n)
.map(|i| Arc::new(json!({"role": "user", "content": format!("m{i}")})))
.collect();
s
}
fn draft(base_len: usize) -> TurnDraft {
TurnDraft {
base_len,
partial_text: "partial".into(),
}
}
fn on_disk(id: &str) -> usize {
Session::load(id).map(|s| s.api_messages.len()).unwrap_or(0)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial(synaps_base_dir)]
async fn the_disk_ends_in_the_last_requested_state() {
let _base = crate::test_env::BaseDirGuard::new();
let dir = agent_core::session_lock::sessions_dir();
let p = Persister::new(dir.clone());
let mut s = session_with(1);
for n in 1..=50 {
s.api_messages = session_with(n).api_messages;
p.save(s.clone());
p.write_draft(&s.id, draft(n));
}
assert!(p.flush().await);
assert!(p.is_idle());
assert_eq!(on_disk(&s.id), 50);
assert_eq!(read_turn_draft(&dir, &s.id).unwrap(), Some(draft(50)));
p.save(s.clone());
p.remove_draft(&s.id);
assert!(p.flush().await);
assert_eq!(read_turn_draft(&dir, &s.id).unwrap(), None);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial(synaps_base_dir)]
async fn operations_for_different_sessions_are_all_applied() {
let _base = crate::test_env::BaseDirGuard::new();
let dir = agent_core::session_lock::sessions_dir();
let a = session_with(2);
let b = session_with(3);
write_turn_draft(&dir, &a.id, &draft(2)).unwrap();
let p = Persister::new(dir.clone());
p.save(a.clone());
p.remove_draft(&a.id);
p.save(b.clone());
p.write_draft(&b.id, draft(3));
assert!(p.flush().await);
assert_eq!((on_disk(&a.id), on_disk(&b.id)), (2, 3));
assert_eq!(read_turn_draft(&dir, &a.id).unwrap(), None, "A's removal kept");
assert_eq!(read_turn_draft(&dir, &b.id).unwrap(), Some(draft(3)));
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial(synaps_base_dir)]
async fn a_removal_waits_for_a_good_save() {
let _base = crate::test_env::BaseDirGuard::new();
let dir = agent_core::session_lock::sessions_dir();
let s = session_with(2);
write_turn_draft(&dir, &s.id, &draft(1)).unwrap();
let snapshot = dir.join(format!("{}.json", s.id));
std::fs::create_dir_all(&snapshot).unwrap();
std::fs::write(snapshot.join("occupied"), b"x").unwrap();
let p = Persister::new(dir.clone());
p.save(s.clone());
p.remove_draft(&s.id);
assert!(!p.flush().await, "the failed save is reported");
assert_eq!(read_turn_draft(&dir, &s.id).unwrap(), Some(draft(1)), "draft kept");
std::fs::remove_dir_all(&snapshot).unwrap();
p.save(s.clone());
assert!(p.flush().await);
assert_eq!(on_disk(&s.id), 2);
assert_eq!(read_turn_draft(&dir, &s.id).unwrap(), None, "removed after the good save");
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial(synaps_base_dir)]
async fn a_failed_save_stays_failed_until_a_save_succeeds() {
let _base = crate::test_env::BaseDirGuard::new();
let dir = agent_core::session_lock::sessions_dir();
let s = session_with(2);
let snapshot = dir.join(format!("{}.json", s.id));
std::fs::create_dir_all(&snapshot).unwrap();
std::fs::write(snapshot.join("occupied"), b"x").unwrap();
let p = Persister::new(dir.clone());
p.save(s.clone());
assert!(!p.flush().await);
p.write_draft(&s.id, draft(2));
assert!(!p.flush().await, "a draft-only batch does not clear the failure");
assert!(!p.saves_ok());
p.remove_draft(&s.id);
assert!(!p.flush().await);
assert_eq!(read_turn_draft(&dir, &s.id).unwrap(), Some(draft(2)), "draft kept");
std::fs::remove_dir_all(&snapshot).unwrap();
p.save(s.clone());
assert!(p.flush().await, "the good save clears it");
assert_eq!(read_turn_draft(&dir, &s.id).unwrap(), None, "and releases the removal");
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial(synaps_base_dir)]
async fn a_new_draft_supersedes_a_held_back_removal() {
let _base = crate::test_env::BaseDirGuard::new();
let dir = agent_core::session_lock::sessions_dir();
let s = session_with(2);
let snapshot = dir.join(format!("{}.json", s.id));
std::fs::create_dir_all(&snapshot).unwrap();
std::fs::write(snapshot.join("occupied"), b"x").unwrap();
let p = Persister::new(dir.clone());
p.save(s.clone());
p.remove_draft(&s.id);
assert!(!p.flush().await);
std::fs::remove_dir_all(&snapshot).unwrap();
p.write_draft(&s.id, draft(9));
p.save(s.clone());
assert!(p.flush().await);
assert_eq!(read_turn_draft(&dir, &s.id).unwrap(), Some(draft(9)));
}
#[test]
fn snapshot_key_ignores_only_the_save_time() {
let s = session_with(2);
let mut later = s.clone();
later.updated_at = chrono::Utc::now() + chrono::Duration::seconds(5);
assert!(SnapshotKey::of(&s) == SnapshotKey::of(&later));
let mut appended = s.clone();
appended.api_messages.push(Arc::new(json!({"role": "user", "content": "more"})));
assert!(SnapshotKey::of(&s) != SnapshotKey::of(&appended));
let mut renamed = s.clone();
renamed.model = "claude-opus-4-6".into();
assert!(SnapshotKey::of(&s) != SnapshotKey::of(&renamed));
let mut costed = s.clone();
costed.session_cost += 0.01;
assert!(SnapshotKey::of(&s) != SnapshotKey::of(&costed));
let mut rebuilt = s.clone();
rebuilt.api_messages[1] = Arc::new((*s.api_messages[1]).clone());
assert!(SnapshotKey::of(&s) != SnapshotKey::of(&rebuilt));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial(synaps_base_dir)]
async fn queued_work_survives_the_drop() {
let _base = crate::test_env::BaseDirGuard::new();
let dir = agent_core::session_lock::sessions_dir();
let s = session_with(4);
let p = Persister::new(dir);
p.save(s.clone());
drop(p);
for _ in 0..200 {
if on_disk(&s.id) == 4 {
return;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
panic!("the queued save never landed");
}
}