fix(agent): log save_or_update as an Update WAL record

A save_or_update that hit an existing record was logged as a plain Save, so
replaying the WAL appended a duplicate instead of updating in place. It is now
logged as WalEntryType::Update (0x04) carrying the target index, and replay
applies it with cache.update().

The WAL header version goes 3 -> 4 for the benefit of older binaries: they
don't know record type 0x04, would read it as a torn tail and truncate it and
everything after it. An unknown header version makes them refuse the file
instead. The framing is otherwise identical, so v3 files are read by the same
code and upgraded in place on open (the header is outside the CRC chain).

Also drop the redundant WAL truncate that several callers ran straight after
flush(), which already truncates.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
osobh
2026-09-19 05:58:04 -07:00
co-authored by Claude Fable 5.1
parent 943b9141e3
commit d4f2d3e7b5
2 changed files with 132 additions and 19 deletions
+3 -13
View File
@@ -629,7 +629,7 @@ impl HDF5Memory {
if let Some(existing_idx) = self.cache.find_by_tags(&entry.tags) {
if let Some(ref mut w) = self.wal {
let wal_entry = wal::WalEntry {
entry_type: wal::WalEntryType::Save,
entry_type: wal::WalEntryType::Update,
timestamp: entry.timestamp,
chunk: entry.chunk.clone(),
embedding: entry.embedding.clone(),
@@ -637,6 +637,7 @@ impl HDF5Memory {
session_id: entry.session_id.clone(),
tags: entry.tags.clone(),
tombstone_index: None,
update_index: Some(existing_idx),
};
w.append_save(&wal_entry)?;
}
@@ -668,9 +669,6 @@ impl HDF5Memory {
.is_none_or(|w| w.pending_count() as usize > self.config.wal_max_entries);
if needs_flush {
self.flush()?;
if let Some(ref mut w) = self.wal {
w.truncate()?;
}
}
return Ok(existing_idx);
}
@@ -691,6 +689,7 @@ impl AgentMemory for HDF5Memory {
session_id: entry.session_id.clone(),
tags: entry.tags.clone(),
tombstone_index: None,
update_index: None,
};
w.append_save(&wal_entry)?;
}
@@ -716,9 +715,6 @@ impl AgentMemory for HDF5Memory {
.is_none_or(|w| w.pending_count() as usize > self.config.wal_max_entries);
if needs_flush {
self.flush()?;
if let Some(ref mut w) = self.wal {
w.truncate()?;
}
}
Ok(idx)
}
@@ -892,9 +888,6 @@ impl HDF5Memory {
*w *= d;
}
self.flush()?;
if let Some(ref mut w) = self.wal {
w.truncate()?;
}
Ok(())
}
@@ -906,9 +899,6 @@ impl HDF5Memory {
/// Explicit WAL merge: flush .h5, truncate WAL.
pub fn flush_wal(&mut self) -> Result<()> {
self.flush()?;
if let Some(ref mut w) = self.wal {
w.truncate()?;
}
Ok(())
}
}