fix(session): preserve checkpoints when reset saves fail [skip ci]

Queue a constructed reset snapshot as one save-then-clear commit, and make coalesced snapshot/clear requests use the same conditional clear. This preserves the recovery checkpoint if the document write fails and avoids a race between separate requests.

Existing save-failure case first failed with the checkpoint removed; 17 persistence, backlog and session lifecycle cases passed on frozen b42759ee9b plus these files, with 2,759 inputs unchanged. Snapshot-construction failure still follows the prior fallback. Linker unwind warning was nonfatal.
This commit is contained in:
CodeWhale Bot committed 2026-10-29 17:05:26 -07:00
1 parent b42759ee9b
commit c4a4f42e9c
2 files changed
+34 -19

No files matched your search

+31 -17
View File
@@ -650,22 +650,22 @@ impl PendingState {
// session only — other sessions' pending work is untouched.
// An explicit clear is also a user-owned boundary (e.g.
// `/new`): it supersedes a pending completion while keeping
// a newer explicit snapshot for the same session.
// a newer explicit snapshot and clearing only after its save.
self.checkpoints.remove(&session_id);
if let Some(PendingSession::Snapshot(session)) =
self.completed_commits.remove(&session_id)
{
self.sessions
.insert(session_id.clone(), PendingSession::Snapshot(session));
let completed = self.completed_commits.remove(&session_id);
let session = self.sessions.remove(&session_id);
let snapshot = match (session, completed) {
(Some(PendingSession::Snapshot(session)), _)
| (_, Some(PendingSession::Snapshot(session))) => Some(session),
_ => None,
};
if let Some(snapshot) = snapshot {
self.checkpoint_clears.remove(&session_id);
self.completed_commits
.insert(session_id, PendingSession::Snapshot(snapshot));
} else {
self.checkpoint_clears.insert(session_id);
}
if self
.sessions
.get(&session_id)
.is_some_and(|session| matches!(session, PendingSession::Completion(_)))
{
self.sessions.remove(&session_id);
}
self.checkpoint_clears.insert(session_id);
}
PersistRequest::FlushAndReport { reply } => return Control::Flush(reply),
PersistRequest::Shutdown => return Control::Shutdown,
@@ -1343,7 +1343,9 @@ mod tests {
block_session_file(&sessions_dir, &session_id);
let (handle, task) = spawn_persistence_actor(manager);
handle.try_send(PersistRequest::CompletedCommit { session });
handle.try_send(PersistRequest::CompletedCommit {
session: session.clone(),
});
let (reply_tx, reply_rx) = tokio::sync::oneshot::channel();
handle.try_send(PersistRequest::FlushAndReport { reply: reply_tx });
let report = reply_rx.await.expect("flush report reply");
@@ -1367,6 +1369,18 @@ mod tests {
checkpoint_path.exists(),
"a failed session save must retain the crash-recovery checkpoint"
);
handle.try_send(PersistRequest::SessionSnapshot(session));
handle.try_send(PersistRequest::ClearCheckpoint {
session_id: session_id.clone(),
});
let (reply_tx, reply_rx) = tokio::sync::oneshot::channel();
handle.try_send(PersistRequest::FlushAndReport { reply: reply_tx });
let report = reply_rx.await.expect("reset flush report reply");
assert!(!report.failures.is_empty());
assert!(
checkpoint_path.exists(),
"a failed reset save must retain the crash-recovery checkpoint"
);
handle.try_send(PersistRequest::Shutdown);
task.await.expect("persistence actor join");
}
@@ -1581,7 +1595,7 @@ mod tests {
session_id: session_id.clone(),
});
assert!(matches!(
&pending.sessions[&session_id],
&pending.completed_commits[&session_id],
PendingSession::Snapshot(session) if session.metadata.title == "Reset session"
));
let saved = verification_manager.load_session(&session_id).unwrap();
@@ -1606,7 +1620,7 @@ mod tests {
session_id: session_id.clone(),
});
assert!(matches!(
&pending.sessions[&session_id],
&pending.completed_commits[&session_id],
PendingSession::Snapshot(session) if session.metadata.title == "Newer reset"
));
}
+3 -2
View File
@@ -487,19 +487,20 @@ pub(crate) fn persist_full_reset_snapshot(app: &mut App) {
app.current_session_id = Some(session.metadata.id.clone());
if let Err(err) = persist_with_pending_work_boundary(
app,
PersistRequest::SessionSnapshot(session),
PersistRequest::CompletedCommit { session },
&projection,
) {
app.status_message = Some(format!(
"To-do list update pending while saving reset snapshot ({err})"
));
}
return;
}
// `/clear` and `/new` are explicit boundaries. Never let an older
// in-flight checkpoint resurrect the session the user just discarded,
// even if the replacement snapshot could not be constructed.
// `build_session_snapshot` reuses `current_session_id`, so this id is the
// discarded session's id whether or not the snapshot above succeeded.
// discarded session's id when the replacement snapshot could not be built.
if let Some(session_id) = app.current_session_id.clone() {
persistence_actor::persist(PersistRequest::ClearCheckpoint { session_id });
}