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(¤t, &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());