Harden ACP session restoration

Fail safely until pending local history can be applied, preserve desired models across transient restore failures, discard cancelled trailing prompts, and avoid redundant fork parking. Co-Authored-By: siGit Code <noreply@sigit.si>

Seto Elkahfi committed Sep 17, 2026 at 08:40 UTC be210818e08d4d7459a1e57a1d22043cbecb075a
2 files changed +86 -17
src/backend.rs
+50 -8
@@ -221,12 +221,18 @@ impl LocalBackend {
221 }
222 }
223
224 - async fn apply_pending_history(&self) {
225 - if self.engine.info().await.status != EngineStatus::Ready {
226 - return;
224 + async fn apply_pending_history(&self) -> Result<(), BackendError> {
225 + if self.pending_history.lock().await.is_none() {
226 + return Ok(());
227 + }
228 + let status = self.engine.info().await.status;
229 + if status != EngineStatus::Ready {
230 + return Err(format!(
231 + "on-device model is not ready to restore session history (status: {status})"
232 + ));
233 }
234 let Some(history) = self.pending_history.lock().await.take() else {
229 - return;
235 + return Ok(());
236 };
237
238 self.engine.clear_history().await;
@@ -248,6 +254,7 @@ impl LocalBackend {
254 };
255 self.engine.push_history(message).await;
256 }
257 + Ok(())
258 }
259 }
260
@@ -270,7 +277,7 @@ impl InferenceBackend for LocalBackend {
277 tools: &[ToolSpec],
278 sink: Option<&TokenSink>,
279 ) -> Result<TurnResult, BackendError> {
273 - self.apply_pending_history().await;
280 + self.apply_pending_history().await?;
281 // onde's tool-aware path is non-streaming: it has to buffer the whole
282 // reply to detect tool calls. We can only stream when no tools are on
283 // offer (a plain answer), which is exactly the tools-disabled case.
@@ -301,7 +308,7 @@ impl InferenceBackend for LocalBackend {
308 allow_tool_calls: bool,
309 sink: Option<&TokenSink>,
310 ) -> Result<TurnResult, BackendError> {
304 - self.apply_pending_history().await;
311 + self.apply_pending_history().await?;
312 let onde_results: Vec<onde::inference::ToolResult> = results
313 .into_iter()
314 .map(|result| onde::inference::ToolResult {
@@ -365,11 +372,15 @@ impl InferenceBackend for LocalBackend {
372
373 async fn restore_history(&self, history: Vec<serde_json::Value>) {
374 *self.pending_history.lock().await = Some(history);
368 - self.apply_pending_history().await;
375 + if self.engine.info().await.status == EngineStatus::Ready {
376 + // Ready was just observed, so failure here can only mean a status
377 + // transition; keep the pending copy for the next inference call.
378 + let _ = self.apply_pending_history().await;
379 + }
380 }
381
382 async fn compact_history(&self, keep_last: usize) -> Result<(), BackendError> {
372 - self.apply_pending_history().await;
383 + self.apply_pending_history().await?;
384 let snapshot = self.engine.history().await;
385 // One plain (tool-free) inference round produces the summary. On error
386 // history is untouched — send_message only mutates it on success, and
@@ -1240,6 +1251,17 @@ pub fn carryover_history(snapshot: Vec<serde_json::Value>) -> Vec<serde_json::Va
1251 }
1252 }
1253
1254 + // Session switches happen only after the serialized turn has ended. A
1255 + // trailing user message therefore belongs to a cancelled/failed inference
1256 + // request with no answer; carrying it would make the next activation feed
1257 + // the model an orphaned prompt that the client considers cancelled.
1258 + while carried
1259 + .last()
1260 + .is_some_and(|message| message["role"] == "user")
1261 + {
1262 + carried.pop();
1263 + }
1264 +
1265 carried
1266 }
1267
@@ -1433,6 +1455,19 @@ mod tests {
1455 assert_eq!(carried[1]["content"], "hi");
1456 }
1457
1458 + #[test]
1459 + fn carryover_drops_a_cancelled_trailing_user_message() {
1460 + let carried = carryover_history(vec![
1461 + serde_json::json!({ "role": "system", "content": "prompt" }),
1462 + serde_json::json!({ "role": "user", "content": "completed question" }),
1463 + serde_json::json!({ "role": "assistant", "content": "completed answer" }),
1464 + serde_json::json!({ "role": "user", "content": "cancelled question" }),
1465 + ]);
1466 +
1467 + assert_eq!(carried.len(), 2, "{carried:#?}");
1468 + assert_eq!(carried.last().unwrap()["content"], "completed answer");
1469 + }
1470 +
1471 #[test]
1472 fn carryover_strips_a_tool_call_that_never_got_a_result() {
1473 // Switching mid-turn: the assistant asked for a tool, nothing answered.
@@ -1613,6 +1648,13 @@ mod tests {
1648 .contains("thread A")),
1649 "restore_history must replace, not append to, the local engine: {restored:#?}"
1650 );
1651 +
1652 + let error = backend
1653 + .send_message_with_tools("must not run", &[], None)
1654 + .await
1655 + .unwrap_err();
1656 + assert!(error.contains("not ready"), "{error}");
1657 + assert_eq!(backend.history_snapshot().await, restored);
1658 }
1659
1660 /// Minimal scripted OpenAI-compatible endpoint: accepts one HTTP request on
src/main.rs
+36 -9
@@ -1526,7 +1526,6 @@ impl SiGitAgent {
1526 let snapshot = self.backend.lock().await.history_snapshot().await;
1527 if let Some(state) = self.sessions.lock().unwrap().get_mut(&active) {
1528 state.conversation = backend::carryover_history(snapshot);
1529 - state.model_id = self.current_model.lock().unwrap().model_id.clone();
1529 }
1530 }
1531
@@ -1539,8 +1538,15 @@ impl SiGitAgent {
1538 /// replaces its parked state (notably `session/load`); every caller holds
1539 /// `turn_lock`, so parking and replacement cannot race another turn.
1540 async fn open_session(&self, session_id: &SessionId, state: SessionState) {
1542 - let key = session_id.to_string();
1541 self.park_active_session().await;
1542 + self.open_parked_session(session_id, state).await;
1543 + }
1544 +
1545 + /// Install a new session after the caller has already parked the live one.
1546 + /// Fork needs this split so it can read an up-to-date source state without
1547 + /// making `open_session` snapshot the same live backend a second time.
1548 + async fn open_parked_session(&self, session_id: &SessionId, state: SessionState) {
1549 + let key = session_id.to_string();
1550 self.sessions
1551 .lock()
1552 .unwrap()
@@ -1643,7 +1649,6 @@ impl SiGitAgent {
1649 log::warn!(
1650 "session({key}): cannot restore cloud model without a signed-in account"
1651 );
1646 - self.record_active_model();
1652 }
1653 return;
1654 }
@@ -1655,7 +1660,6 @@ impl SiGitAgent {
1660 }
1661 Err(error) => {
1662 log::warn!("session({key}): could not restore model {target}: {error}");
1658 - self.record_active_model();
1663 }
1664 }
1665 }
@@ -1702,9 +1706,6 @@ impl SiGitAgent {
1706 /// wrong project is worse than one that isn't listed at all.
1707 async fn persist_session(&self, session_id: &SessionId, snapshot: &[serde_json::Value]) {
1708 let key = session_id.to_string();
1705 - if self.active_session.lock().unwrap().as_deref() == Some(key.as_str()) {
1706 - self.record_active_model();
1707 - }
1709 if let Err(error) = session_store::save(&key, snapshot) {
1710 log::warn!("session({session_id}) save failed: {error}");
1711 return;
@@ -1881,7 +1882,7 @@ impl SiGitAgent {
1882 cx.send_notification(SessionNotification::new(new_id.clone(), update))
1883 .ok();
1884 }
1884 - self.open_session(&new_id, state).await;
1885 + self.open_parked_session(&new_id, state).await;
1886 log::info!(
1887 "fork_session: copied {copied} message(s), replayed {replayed} update(s) for {new_id}"
1888 );
@@ -2414,6 +2415,9 @@ impl SiGitAgent {
2415 tool_call_id: tc.id.clone(),
2416 content: output,
2417 });
2418 + // Cancellation cannot preempt a synchronous foreground tool:
2419 + // it is observed here as soon as that tool exits. Background
2420 + // commands remain cancellable through `kill_command`.
2421 if cancellation.cancelled.load(Ordering::Acquire) {
2422 for pending in &result.tool_calls[call_index + 1..] {
2423 tool_results.push(BackendToolResult {
@@ -2892,6 +2896,12 @@ impl SiGitAgent {
2896 "set_session_config_option: {} is already the active selection, skipping",
2897 current.display_name
2898 );
2899 + // A restored preferred model may have fallen back temporarily.
2900 + // Explicitly picking the live model adopts that fallback as the
2901 + // session's new preference even though no backend switch runs.
2902 + drop(current);
2903 + self.record_active_model();
2904 + let current = self.current_model.lock().unwrap();
2905 let config_options = build_config_options(&current, &args.session_id.to_string());
2906 return Ok(SetSessionConfigOptionResponse::new(config_options));
2907 }
@@ -4655,11 +4665,12 @@ mod tests {
4665 .join("\n")
4666 }
4667
4658 - /// Append a user message to the live conversation.
4668 + /// Append one completed exchange to the live conversation.
4669 async fn say(agent: &SiGitAgent, text: &str) {
4670 let backend = agent.backend.lock().await.clone();
4671 let mut history = backend.history_snapshot().await;
4672 history.push(serde_json::json!({ "role": "user", "content": text }));
4673 + history.push(serde_json::json!({ "role": "assistant", "content": "acknowledged" }));
4674 backend.restore_history(history).await;
4675 }
4676
@@ -4851,6 +4862,22 @@ mod tests {
4862 assert_eq!(sessions["thread-b"].model_id, "sigit-cloud:test-b");
4863 drop(sessions);
4864
4865 + // A transient restore failure may leave another model live, but parking
4866 + // must not erase the session's desired selection; its next activation
4867 + // should retry that preference.
4868 + agent
4869 + .sessions
4870 + .lock()
4871 + .unwrap()
4872 + .get_mut("thread-b")
4873 + .unwrap()
4874 + .model_id = "sigit-cloud:preferred".into();
4875 + agent.park_active_session().await;
4876 + assert_eq!(
4877 + agent.sessions.lock().unwrap()["thread-b"].model_id,
4878 + "sigit-cloud:preferred"
4879 + );
4880 +
4881 std::env::set_current_dir(prev_cwd).unwrap();
4882 tools::set_active_session(None);
4883 workspace::replace_additional_roots(Vec::new());