924
/// What one ACP session owns, kept per session id.
925
///
926
/// An editor runs a single sigit process for every thread it has open, but the
927
-/// process has one working directory, one set of workspace roots, and one
928
-/// backend conversation. Only one session can be live at a time (turns are
929
-/// serialized by `turn_lock`), so each session's roots and conversation are
930
-/// parked here while another session holds the process, and swapped back in
931
-/// by `activate_session` before its next request runs.
927
+/// process has one working directory and one set of workspace roots. Only one
928
+/// session can be installed at a time (see `workspace_lock`), so each session's
929
+/// roots are parked here while another session holds the process, and swapped
930
+/// back in by `activate_session` before its next request runs.
931
+///
932
+/// The conversation is parked the same way on-device, where the engine holds
933
+/// it. A session on an HTTP backend keeps a backend of its own instead (see
934
+/// `remote`), which is what lets its turn wait on the endpoint while another
935
+/// session is installed.
936
#[derive(Clone, Debug)]
937
struct SessionState {
938
cwd: PathBuf,
950
/// The MCP servers the client passed in `mcpServers` for this session,
951
/// connected. Installed as the live set along with the roots.
952
mcp_servers: mcp::SessionServers,
953
+ /// This session's own HTTP backend, set while the session is parked and
954
+ /// was last served by one. `None` for a session that has not run yet, was
955
+ /// last on-device, or is the installed session (the agent's `backend`
956
+ /// holds it then).
957
+ remote: Option<RemoteBackend>,
958
+}
959
+
960
+/// An HTTP backend that belongs to one session, with the model selection that
961
+/// was live when the session was parked.
962
+#[derive(Clone)]
963
+struct RemoteBackend {
964
+ backend: Arc<dyn InferenceBackend>,
965
+ model: GgufModelConfig,
966
+}
967
+
968
+impl std::fmt::Debug for RemoteBackend {
969
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
970
+ f.debug_struct("RemoteBackend")
971
+ .field("model", &self.model.model_id)
972
+ .finish_non_exhaustive()
973
+ }
974
+}
975
+
976
+/// Holds `workspace_lock` for a prompt turn, which gives it up while it waits
977
+/// on an HTTP endpoint or on the user and takes it back before touching
978
+/// anything process-wide.
979
+struct WorkspaceHold {
980
+ lock: Arc<tokio::sync::Mutex<()>>,
981
+ guard: Option<tokio::sync::OwnedMutexGuard<()>>,
982
+}
983
+
984
+impl WorkspaceHold {
985
+ async fn acquire(lock: &Arc<tokio::sync::Mutex<()>>) -> Self {
986
+ Self {
987
+ lock: Arc::clone(lock),
988
+ guard: Some(Arc::clone(lock).lock_owned().await),
989
+ }
990
+ }
991
+
992
+ fn release(&mut self) {
993
+ self.guard = None;
994
+ }
995
+
996
+ /// Whether the lock was given up since it was last held.
997
+ async fn reacquire(&mut self) -> bool {
998
+ if self.guard.is_some() {
999
+ return false;
1000
+ }
1001
+ self.guard = Some(Arc::clone(&self.lock).lock_owned().await);
1002
+ true
1003
+ }
1004
}
1005
1006
struct SiGitAgent {
1016
/// The session whose roots and conversation are installed right now.
1017
active_session: std::sync::Mutex<Option<String>>,
1018
/// Cancellation signals for prompts currently waiting on inference. The
964
- /// notification handler runs outside `turn_lock`, so it can interrupt the
965
- /// turn that currently owns that lock and let another session proceed.
1019
+ /// notification handler takes no lock, so it can interrupt a turn whatever
1020
+ /// that turn is holding.
1021
prompt_cancellations:
1022
std::sync::Mutex<std::collections::HashMap<String, Arc<PromptCancellation>>>,
1023
current_model: std::sync::Mutex<GgufModelConfig>,
1036
startup_model_name: String,
1037
/// for download-progress polling
1038
startup_model_id: String,
984
- /// Serializes turn-affecting handlers (prompt, session lifecycle, config
985
- /// changes). They run in `cx.spawn`ed tasks so the JSON-RPC dispatch loop
986
- /// stays free to route client responses (e.g. permission answers) mid-turn;
987
- /// this lock reproduces the strict ordering the dispatch loop used to give
988
- /// them for free.
989
- turn_lock: Arc<tokio::sync::Mutex<()>>,
1039
+ /// Guards everything one session installs process-wide: the working
1040
+ /// directory, workspace roots, session MCP servers, the live `backend`
1041
+ /// slot and the on-device engine's conversation. Session lifecycle and
1042
+ /// config handlers hold it throughout. A prompt turn holds it while it
1043
+ /// prepares and while its tools run, and gives it up while it waits on an
1044
+ /// HTTP endpoint or on a permission answer, so another session can be
1045
+ /// installed in the meantime. An on-device turn never gives it up: the
1046
+ /// engine serves one conversation at a time.
1047
+ workspace_lock: Arc<tokio::sync::Mutex<()>>,
1048
+ /// One lock per session id, held for a whole request on that session, so
1049
+ /// two requests on the same thread keep the order they arrived in. Always
1050
+ /// taken before `workspace_lock`, never while holding it.
1051
+ session_locks: std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
1052
}
1053
1054
impl SiGitAgent {
1079
auto_load_local_model,
1080
startup_model_name,
1081
startup_model_id,
1020
- turn_lock: Arc::new(tokio::sync::Mutex::new(())),
1082
+ workspace_lock: Arc::new(tokio::sync::Mutex::new(())),
1083
+ session_locks: std::sync::Mutex::new(std::collections::HashMap::new()),
1084
}
1085
}
1086
1917
model_id,
1918
conversation: Vec::new(),
1919
mcp_servers: mcp::SessionServers::default(),
1920
+ remote: None,
1921
}
1922
}
1923
1924
+ /// The lock that orders requests on one session (see `session_locks`).
1925
+ fn session_lock(&self, session_id: &SessionId) -> Arc<tokio::sync::Mutex<()>> {
1926
+ Arc::clone(
1927
+ self.session_locks
1928
+ .lock()
1929
+ .unwrap()
1930
+ .entry(session_id.to_string())
1931
+ .or_default(),
1932
+ )
1933
+ }
1934
+
1935
/// Keep the active session's parked metadata in step with a successful
1936
/// model/backend switch. Its conversation remains stale while active.
1937
fn record_active_model(&self) {
1945
}
1946
1947
/// Move the live conversation into the state of the session that owns it,
1873
- /// so installing another session can't lose or leak it.
1948
+ /// so installing another session can't lose or leak it. An HTTP backend
1949
+ /// goes with it: the session may be waiting on that backend right now, and
1950
+ /// its next request has to find the same one.
1951
async fn park_active_session(&self) {
1952
let Some(active) = self.active_session.lock().unwrap().clone() else {
1953
return;
1954
};
1878
- let snapshot = self.backend.lock().await.history_snapshot().await;
1955
+ let live = self.backend.lock().await.clone();
1956
+ let snapshot = live.history_snapshot().await;
1957
+ let remote = live.is_remote().then(|| RemoteBackend {
1958
+ backend: live,
1959
+ model: self.current_model.lock().unwrap().clone(),
1960
+ });
1961
if let Some(state) = self.sessions.lock().unwrap().get_mut(&active) {
1962
state.conversation = backend::carryover_history(snapshot);
1963
+ state.remote = remote;
1964
}
1965
}
1966
1971
/// backend installed here starts from this session's conversation and
1972
/// system prompt and nobody else's. Reopening an existing id deliberately
1973
/// replaces its parked state (notably `session/load`); every caller holds
1891
- /// `turn_lock`, so parking and replacement cannot race another turn.
1974
+ /// `workspace_lock`, so parking and replacement cannot race another
1975
+ /// session being installed.
1976
async fn open_session(&self, session_id: &SessionId, state: SessionState) {
1977
self.park_active_session().await;
1978
self.open_parked_session(session_id, state).await;
2026
Ok(())
2027
}
2028
2029
+ /// Take `workspace_lock` back after a turn gave it up, and make the turn's
2030
+ /// session the installed one again if another was installed meanwhile.
2031
+ async fn resume_workspace(&self, workspace: &mut WorkspaceHold, session_id: &SessionId) {
2032
+ if workspace.reacquire().await
2033
+ && let Err(error) = self.activate_session(session_id).await
2034
+ {
2035
+ log::warn!(
2036
+ "session({session_id}): could not reinstall mid-turn: {}",
2037
+ error.message
2038
+ );
2039
+ }
2040
+ }
2041
+
2042
/// Point the process at `state`'s roots and load its conversation.
2043
///
2044
/// The process follows `cwd` because tool calls may use relative paths; the
2049
let SessionState {
2050
cwd,
2051
additional_roots,
1955
- model_id: _,
2052
+ model_id,
2053
conversation,
2054
mcp_servers,
2055
+ remote,
2056
} = state;
2057
2058
if let Ok(mut guard) = self.session_cwd.lock() {
2067
}
2068
*self.active_session.lock().unwrap() = Some(key.to_string());
2069
tools::set_active_session(Some(key));
2070
+ if let Some(parked) = self.sessions.lock().unwrap().get_mut(key) {
2071
+ parked.remote = None;
2072
+ }
2073
2074
+ // A session that already has its own HTTP backend goes back onto it
2075
+ // untouched. Its conversation lives there and may be mid-turn, so it
2076
+ // is not reseeded from the parked copy. The engine still gets this
2077
+ // session's context, for a later switch to an on-device model.
2078
+ if let Some(RemoteBackend { backend, model }) = remote
2079
+ && model.model_id == model_id
2080
+ {
2081
+ self.seed_engine_context(&session_context_message(&cwd, &additional_roots))
2082
+ .await;
2083
+ *self.backend.lock().await = backend;
2084
+ *self.current_model.lock().unwrap() = model;
2085
+ return;
2086
+ }
2087
+
2088
+ // Otherwise the session starts from its parked conversation. The live
2089
+ // HTTP backend belongs to whichever session was installed before, and
2090
+ // that session may still be waiting on it, so this one gets a backend
2091
+ // of its own for the same endpoint.
2092
+ let fresh = self.backend.lock().await.fresh();
2093
+ if let Some(fresh) = fresh {
2094
+ *self.backend.lock().await = fresh;
2095
+ }
2096
self.seed_conversation(&cwd, &additional_roots, conversation)
2097
.await;
2098
}
2099
2100
+ /// Reset the on-device engine's conversation to just `context`.
2101
+ async fn seed_engine_context(&self, context: &str) {
2102
+ self.engine.clear_history().await;
2103
+ self.engine
2104
+ .push_history(onde::inference::ChatMessage::system(context.to_string()))
2105
+ .await;
2106
+ }
2107
+
2108
/// Restore the active session's model/backend after its conversation has
2109
/// been installed on the currently live backend. Model switches carry that
2110
/// freshly installed conversation across to the target backend.
2165
let backend = self.backend.lock().await.clone();
2166
2167
if backend.is_remote() {
2037
- self.engine.clear_history().await;
2038
- self.engine
2039
- .push_history(onde::inference::ChatMessage::system(context.clone()))
2040
- .await;
2168
+ self.seed_engine_context(&context).await;
2169
}
2170
2171
let system_prompt = if backend.is_remote() {
2468
2469
// Everything below (slash commands included) acts on the live session,
2470
// so it has to be this one. Resource links are read relative to its cwd.
2471
+ let mut workspace = WorkspaceHold::acquire(&self.workspace_lock).await;
2472
self.activate_session(&session_id).await?;
2473
2474
// log every block so we can debug @ references and file context
2649
// The active backend drives the turn. Snapshot it once so a mid-turn
2650
// model switch doesn't split the conversation across backends.
2651
let backend = self.backend.lock().await.clone();
2652
+ // Waiting on an HTTP endpoint uses nothing process-wide, so another
2653
+ // session may be installed meanwhile. The on-device engine holds one
2654
+ // conversation, so an on-device turn keeps the workspace throughout.
2655
+ let release_while_waiting = backend.is_remote();
2656
2657
// Only on-device inference needs a local model in memory. Cloud tiers run
2658
// over the network, so they never need a local model. Xcode's explicit
2735
None
2736
};
2737
2605
- let mut result = match self
2738
+ if release_while_waiting {
2739
+ workspace.release();
2740
+ }
2741
+ let outcome = self
2742
.drain_turn(
2743
cx,
2744
&session_id,
2747
&mut reply,
2748
&cancellation,
2749
)
2614
- .await
2615
- {
2750
+ .await;
2751
+ self.resume_workspace(&mut workspace, &session_id).await;
2752
+ let mut result = match outcome {
2753
Ok(result) => result,
2754
Err(DrainTurnError::Cancelled) => {
2755
backend::forget_trailing_user_message(backend.as_ref()).await;
2933
reason
2934
}
2935
permissions::Decision::Ask => {
2799
- match self
2936
+ // The answer can take as long as the user likes.
2937
+ if release_while_waiting {
2938
+ workspace.release();
2939
+ }
2940
+ let verdict = self
2941
.request_tool_permission(
2942
cx,
2943
&session_id,
2945
&tc.name,
2946
&tc.arguments,
2947
)
2807
- .await
2808
- {
2948
+ .await;
2949
+ self.resume_workspace(&mut workspace, &session_id).await;
2950
+ match verdict {
2951
PermissionVerdict::Approved => {
2952
// The call the user just approved leaves
2953
// `pending` here. The title goes back to
3074
sent_before_last_round = reply.sent.len();
3075
3076
let cancelled_results = tool_results.clone();
2935
- result = match self
3077
+ if release_while_waiting {
3078
+ workspace.release();
3079
+ }
3080
+ let outcome = self
3081
.drain_turn(
3082
cx,
3083
&session_id,
3086
&mut reply,
3087
&cancellation,
3088
)
2944
- .await
2945
- {
3089
+ .await;
3090
+ self.resume_workspace(&mut workspace, &session_id).await;
3091
+ result = match outcome {
3092
Ok(result) => result,
3093
Err(DrainTurnError::Cancelled) => {
3094
backend
5132
},
5133
agent_client_protocol::on_receive_request!(),
5134
)
4989
- // Turn-affecting handlers below run in spawned tasks, serialized by
4990
- // `turn_lock`, so the dispatch loop stays free to route client
4991
- // responses (permission answers) while a turn is in flight. Awaiting a
4992
- // client request from *inside* a handler would deadlock: the dispatch
4993
- // loop can't read the response while the handler blocks it.
5135
+ // Turn-affecting handlers below run in spawned tasks, so the dispatch
5136
+ // loop stays free to route client responses (permission answers) while
5137
+ // a turn is in flight. Awaiting a client request from *inside* a
5138
+ // handler would deadlock: the dispatch loop can't read the response
5139
+ // while the handler blocks it. Requests on one session keep their
5140
+ // order through that session's lock; whatever touches process-wide
5141
+ // state takes `workspace_lock` (a prompt does so itself, in pieces).
5142
.on_receive_request(
5143
{
5144
let state = Arc::clone(&state);
5146
let state = Arc::clone(&state);
5147
let task_cx = cx.clone();
5148
cx.spawn(async move {
5001
- let _turn = state.turn_lock.lock().await;
5149
+ let session = state.session_lock(&req.session_id);
5150
+ let _session = session.lock().await;
5151
+ let _workspace = state.workspace_lock.lock().await;
5152
handle_response(responder, state.handle_load_session(&task_cx, req).await)
5153
})
5154
}
5162
let state = Arc::clone(&state);
5163
let task_cx = cx.clone();
5164
cx.spawn(async move {
5015
- let _turn = state.turn_lock.lock().await;
5165
+ // The source's lock: a fork copies its conversation,
5166
+ // which has to be between turns.
5167
+ let session = state.session_lock(&req.session_id);
5168
+ let _session = session.lock().await;
5169
+ let _workspace = state.workspace_lock.lock().await;
5170
handle_response(responder, state.handle_fork_session(&task_cx, req).await)
5171
})
5172
}
5180
let state = Arc::clone(&state);
5181
let task_cx = cx.clone();
5182
cx.spawn(async move {
5029
- let _turn = state.turn_lock.lock().await;
5183
+ let _workspace = state.workspace_lock.lock().await;
5184
handle_response(responder, state.handle_new_session(&task_cx, req).await)
5185
})
5186
}
5194
let state = Arc::clone(&state);
5195
let task_cx = cx.clone();
5196
cx.spawn(async move {
5043
- let _turn = state.turn_lock.lock().await;
5197
+ let session = state.session_lock(&req.session_id);
5198
+ let _session = session.lock().await;
5199
handle_response(responder, state.handle_prompt(&task_cx, req).await)
5200
})
5201
}
5211
let state = Arc::clone(&state);
5212
let task_cx = cx.clone();
5213
cx.spawn(async move {
5059
- let _turn = state.turn_lock.lock().await;
5214
+ let session = state.session_lock(&req.session_id);
5215
+ let _session = session.lock().await;
5216
+ let _workspace = state.workspace_lock.lock().await;
5217
handle_response(
5218
responder,
5219
state.handle_set_session_config_option(&task_cx, req).await,
5230
let state = Arc::clone(&state);
5231
let task_cx = cx.clone();
5232
cx.spawn(async move {
5076
- let _turn = state.turn_lock.lock().await;
5233
+ let session = state.session_lock(&req.session_id);
5234
+ let _session = session.lock().await;
5235
+ let _workspace = state.workspace_lock.lock().await;
5236
handle_response(
5237
responder,
5238
state.handle_set_session_mode(&task_cx, req).await,
5830
std::fs::remove_dir_all(&temp).ok();
5831
}
5832
5833
+ /// A turn that is waiting on its endpoint holds on to its backend while
5834
+ /// another session is installed. Coming back, the session has to find that
5835
+ /// same backend, with whatever the turn added to it, and the other session
5836
+ /// must never have been given it.
5837
+ #[tokio::test]
5838
+ #[allow(clippy::await_holding_lock)] // the lock guards process-global cwd/env for the whole test
5839
+ async fn a_session_on_an_http_backend_keeps_that_backend_across_other_sessions() {
5840
+ let _guard = crate::ENV_TEST_LOCK
5841
+ .lock()
5842
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
5843
+ let temp = std::env::temp_dir().join(format!("sigit-own-backend-{}", uuid::Uuid::new_v4()));
5844
+ let (repo_a, repo_b) = (temp.join("repo-a"), temp.join("repo-b"));
5845
+ for dir in [&repo_a, &repo_b] {
5846
+ std::fs::create_dir_all(dir).unwrap();
5847
+ }
5848
+ let prev_cwd = std::env::current_dir().unwrap();
5849
+ // SAFETY: serialized by ENV_TEST_LOCK and restored below.
5850
+ unsafe {
5851
+ std::env::set_var("SIGIT_CONFIG_DIR", temp.join("config"));
5852
+ std::env::set_var("SIGIT_LOCAL_INFERENCE", "on");
5853
+ }
5854
+
5855
+ let agent = SiGitAgent::new(
5856
+ Arc::new(ChatEngine::new()),
5857
+ default_local_model_config(),
5858
+ Arc::new(AtomicBool::new(true)),
5859
+ Arc::new(AtomicBool::new(false)),
5860
+ Arc::new(std::sync::Mutex::new(None)),
5861
+ false,
5862
+ false,
5863
+ );
5864
+ // No request is ever sent.
5865
+ *agent.backend.lock().await = Arc::new(OpenAiBackend::new(
5866
+ "http://127.0.0.1:9",
5867
+ "",
5868
+ "m",
5869
+ Some("base prompt".into()),
5870
+ ));
5871
+ let thread_a = SessionId::new("thread-a");
5872
+ let thread_b = SessionId::new("thread-b");
5873
+
5874
+ agent
5875
+ .open_session(&thread_a, agent.session_state(&repo_a, &[]))
5876
+ .await;
5877
+ say(&agent, "first turn in a").await;
5878
+ let backend_a = agent.backend.lock().await.clone();
5879
+
5880
+ agent
5881
+ .open_session(&thread_b, agent.session_state(&repo_b, &[]))
5882
+ .await;
5883
+ let backend_b = agent.backend.lock().await.clone();
5884
+ assert!(
5885
+ !Arc::ptr_eq(&backend_a, &backend_b),
5886
+ "thread B was handed thread A's backend"
5887
+ );
5888
+ let history = history_text(&live(&agent).await);
5889
+ assert!(!history.contains("first turn in a"), "{history}");
5890
+ say(&agent, "first turn in b").await;
5891
+
5892
+ // Thread A's turn, still in flight, adds to its own backend while
5893
+ // thread B is the installed session.
5894
+ let mut in_flight = backend_a.history_snapshot().await;
5895
+ in_flight.push(serde_json::json!({ "role": "assistant", "content": "mid-turn in a" }));
5896
+ backend_a.restore_history(in_flight).await;
5897
+ let history = history_text(&live(&agent).await);
5898
+ assert!(!history.contains("mid-turn in a"), "{history}");
5899
+
5900
+ agent.activate_session(&thread_a).await.unwrap();
5901
+ assert!(Arc::ptr_eq(&*agent.backend.lock().await, &backend_a));
5902
+ let history = history_text(&live(&agent).await);
5903
+ assert!(history.contains("first turn in a"), "{history}");
5904
+ assert!(history.contains("mid-turn in a"), "{history}");
5905
+ assert!(!history.contains("first turn in b"), "{history}");
5906
+ assert_eq!(
5907
+ workspace::canonical_key(&std::env::current_dir().unwrap()),
5908
+ workspace::canonical_key(&repo_a)
5909
+ );
5910
+
5911
+ agent.activate_session(&thread_b).await.unwrap();
5912
+ assert!(Arc::ptr_eq(&*agent.backend.lock().await, &backend_b));
5913
+ let history = history_text(&live(&agent).await);
5914
+ assert!(history.contains("first turn in b"), "{history}");
5915
+ assert!(!history.contains("mid-turn in a"), "{history}");
5916
+
5917
+ std::env::set_current_dir(prev_cwd).unwrap();
5918
+ tools::set_active_session(None);
5919
+ workspace::replace_additional_roots(Vec::new());
5920
+ // SAFETY: serialized by ENV_TEST_LOCK.
5921
+ unsafe {
5922
+ std::env::remove_var("SIGIT_CONFIG_DIR");
5923
+ std::env::remove_var("SIGIT_LOCAL_INFERENCE");
5924
+ }
5925
+ std::fs::remove_dir_all(&temp).ok();
5926
+ }
5927
+
5928
#[test]
5929
fn session_context_message_lists_every_root_of_a_multi_root_project() {
5930
let primary = PathBuf::from("/tmp/primary");