Skip to content

Commit 8264873

Browse files
authored
fix(acp): recover dead ACP connections (#487)
## Summary - Recover ACP sessions after underlying stdout/ACP connections die by evicting stale tasks and recreating runtime state on demand. - Persist and replay ACP config selections through session config so resumed sessions keep selected mode/model/config values. - Remove obsolete direct ACP mode/model mutation entrypoints and route callers through config option updates. ## Test Plan - `just push -u origin phase1-acp-dead-connection-recovery` - migration immutability check - cargo fix / clippy fix / fmt - cargo nextest run --workspace: 6311 passed, 18 skipped - pushed branch to origin --------- Co-authored-by: zynx <>
1 parent a0b3737 commit 8264873

8 files changed

Lines changed: 182 additions & 272 deletions

File tree

crates/aionui-ai-agent/src/agent_task.rs

Lines changed: 0 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -110,21 +110,9 @@ pub trait IMockAgent: IAgentTask {
110110
initialized: false,
111111
})
112112
}
113-
async fn set_mode(&self, _mode: &str) -> Result<(), AgentError> {
114-
Err(AgentError::bad_request("Mode switching is not supported for this mock"))
115-
}
116113
async fn get_model(&self) -> Result<GetModelInfoResponse, AgentError> {
117114
Ok(GetModelInfoResponse { model_info: None })
118115
}
119-
async fn set_model(&self, _model_id: &str) -> Result<(), AgentError> {
120-
Err(AgentError::bad_request(
121-
"Model switching is not supported for this mock",
122-
))
123-
}
124-
async fn set_model_confirmed(&self, model_id: &str) -> Result<GetModelInfoResponse, AgentError> {
125-
self.set_model(model_id).await?;
126-
self.get_model().await
127-
}
128116
async fn get_config_options(&self) -> Result<GetConfigOptionsResponse, AgentError> {
129117
Ok(GetConfigOptionsResponse {
130118
config_options: Vec::new(),
@@ -316,18 +304,6 @@ impl AgentInstance {
316304
}
317305
}
318306

319-
/// Set the session mode. Unsupported for variants other than ACP /
320-
/// Aionrs — returns a `BadRequest` so the caller can surface an
321-
/// actionable error rather than silently no-op.
322-
pub async fn set_mode(&self, mode: &str) -> Result<(), AgentError> {
323-
match self {
324-
Self::Acp(m) => m.set_mode(mode).await,
325-
Self::Aionrs(m) => m.set_mode(mode).await,
326-
#[cfg(any(test, feature = "test-support"))]
327-
Self::Mock(m) => m.set_mode(mode).await,
328-
}
329-
}
330-
331307
/// Get the current session model info. Only ACP exposes a model
332308
/// catalog; other variants report `model_info = None` so the UI can
333309
/// hide the model picker without an error.
@@ -350,42 +326,6 @@ impl AgentInstance {
350326
}
351327
}
352328

353-
/// Switch the active model. Unsupported for variants other than ACP —
354-
/// returns a `BadRequest` so the caller can surface an actionable
355-
/// error rather than silently no-op.
356-
pub async fn set_model(&self, model_id: &str) -> Result<(), AgentError> {
357-
if model_id.trim().is_empty() {
358-
return Err(AgentError::bad_request("model_id must not be empty"));
359-
}
360-
match self {
361-
Self::Acp(m) => m.set_model(model_id).await,
362-
Self::Aionrs(_) => Err(AgentError::bad_request(
363-
"Model switching is not supported for this agent type",
364-
)),
365-
#[cfg(any(test, feature = "test-support"))]
366-
Self::Mock(m) => m.set_model(model_id).await,
367-
}
368-
}
369-
370-
/// Switch the active model and return the confirmed model payload for
371-
/// this specific mutation, rather than re-reading potentially stale
372-
/// cached state.
373-
pub async fn set_model_confirmed(&self, model_id: &str) -> Result<GetModelInfoResponse, AgentError> {
374-
if model_id.trim().is_empty() {
375-
return Err(AgentError::bad_request("model_id must not be empty"));
376-
}
377-
match self {
378-
Self::Acp(m) => Ok(GetModelInfoResponse {
379-
model_info: Some(map_sdk_model_to_payload(m.set_model_confirmed(model_id).await?)),
380-
}),
381-
Self::Aionrs(_) => Err(AgentError::bad_request(
382-
"Model switching is not supported for this agent type",
383-
)),
384-
#[cfg(any(test, feature = "test-support"))]
385-
Self::Mock(m) => m.set_model_confirmed(model_id).await,
386-
}
387-
}
388-
389329
pub async fn get_config_options(&self) -> Result<GetConfigOptionsResponse, AgentError> {
390330
match self {
391331
Self::Acp(m) => m.config_options().await,

crates/aionui-ai-agent/src/manager/acp/agent.rs

Lines changed: 70 additions & 174 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,25 @@ fn matched_slash_command(raw_user_input: &str, commands: &[AvailableCommand]) ->
245245
/// (Claude, Qwen, CodeBuddy, Codex, etc.). Communication now happens via
246246
/// the `agent-client-protocol` SDK's JSON-RPC transport, replacing the
247247
/// previous hand-crafted JSON-over-stdin/stdout approach.
248+
fn mark_session_opened_after_protocol_ready(
249+
session: &mut AcpSession,
250+
sid: String,
251+
protocol_connected: bool,
252+
conversation_id: &str,
253+
backend: Option<&str>,
254+
) -> Result<String, AgentError> {
255+
if !protocol_connected {
256+
warn!(
257+
conversation_id = %conversation_id,
258+
backend = backend.unwrap_or("-"),
259+
"ACP session open returned after protocol disconnected; rejecting opened transition"
260+
);
261+
return Err(AcpError::NotConnected.into());
262+
}
263+
session.mark_opened();
264+
Ok(sid)
265+
}
266+
248267
pub struct AcpAgentManager {
249268
/// Pre-computed, immutable session parameters assembled by the factory.
250269
pub(super) params: Arc<AcpSessionParams>,
@@ -465,6 +484,19 @@ impl AcpAgentManager {
465484
runtime.bump_activity();
466485
}
467486

487+
fn ensure_protocol_connected_for_operation(&self, operation: &'static str) -> Result<(), AgentError> {
488+
if self.protocol.is_connected() {
489+
return Ok(());
490+
}
491+
warn!(
492+
conversation_id = %self.params.conversation_id,
493+
agent_backend = ?self.params.metadata.backend,
494+
operation,
495+
"ACP operation rejected because protocol is disconnected"
496+
);
497+
Err(AcpError::NotConnected.into())
498+
}
499+
468500
pub(crate) async fn mode(&self) -> Result<aionui_api_types::AgentModeResponse, AgentError> {
469501
let desired = self
470502
.session
@@ -551,6 +583,8 @@ impl AcpAgentManager {
551583
option_id: &str,
552584
value: &str,
553585
) -> Result<SetConfigOptionResponse, AgentError> {
586+
self.ensure_protocol_connected_for_operation("set_config_option")?;
587+
554588
let (session_id, set_path, is_mode_option) = {
555589
let session = self.session.read().await;
556590
let snapshot = session.config_snapshot();
@@ -761,176 +795,6 @@ impl AcpAgentManager {
761795
}
762796
}
763797

764-
/// Set the mode for the current session.
765-
pub(crate) async fn set_mode(&self, mode: &str) -> Result<(), AgentError> {
766-
let normalized_mode = normalize_requested_mode(&self.params.metadata, mode);
767-
if normalized_mode.is_empty() {
768-
return Err(AgentError::bad_request("mode must not be empty"));
769-
}
770-
771-
let session_id = {
772-
let session = self.session.read().await;
773-
if !session.can_select_mode(&normalized_mode) {
774-
warn!(
775-
conversation_id = %self.params.conversation_id,
776-
agent_backend = ?self.params.metadata.backend,
777-
requested_mode_id = %normalized_mode,
778-
"acp_set_mode_rejected_unavailable"
779-
);
780-
return Err(AgentError::bad_request(format!(
781-
"Mode '{normalized_mode}' is not available for this ACP session"
782-
)));
783-
}
784-
session.session_id().map(ToOwned::to_owned)
785-
}
786-
.ok_or_else(|| {
787-
warn!(
788-
conversation_id = %self.params.conversation_id,
789-
agent_backend = ?self.params.metadata.backend,
790-
requested_mode_id = %normalized_mode,
791-
"acp_set_command_missing_session"
792-
);
793-
AgentError::bad_request("No active session")
794-
})?;
795-
796-
info!(
797-
conversation_id = %self.params.conversation_id,
798-
agent_backend = ?self.params.metadata.backend,
799-
requested_mode_id = %normalized_mode,
800-
"acp_set_mode_requested"
801-
);
802-
codex_sandbox::sync_for_agent(&self.params.metadata, Some(&normalized_mode)).await;
803-
804-
if let Err(e) = self
805-
.protocol
806-
.set_mode(SetSessionModeRequest::new(
807-
SessionId::new(session_id.clone()),
808-
normalized_mode.clone(),
809-
))
810-
.await
811-
{
812-
warn!(
813-
conversation_id = %self.params.conversation_id,
814-
agent_backend = ?self.params.metadata.backend,
815-
requested_mode_id = %normalized_mode,
816-
error = %e,
817-
"acp_set_mode_failed"
818-
);
819-
return Err(AgentError::from(e));
820-
}
821-
822-
let mut session = self.session.write().await;
823-
if session.session_id() != Some(session_id.as_str()) {
824-
warn!(
825-
conversation_id = %self.params.conversation_id,
826-
agent_backend = ?self.params.metadata.backend,
827-
requested_mode_id = %normalized_mode,
828-
confirmed_session_id = %session_id,
829-
active_session_id = ?session.session_id(),
830-
"acp_set_mode_session_changed"
831-
);
832-
return Err(AgentError::conflict("Active ACP session changed while applying mode"));
833-
}
834-
session.confirm_mode(ModeId::new(&normalized_mode));
835-
self.commit_session_changes(&mut session).await;
836-
info!(
837-
conversation_id = %self.params.conversation_id,
838-
agent_backend = ?self.params.metadata.backend,
839-
confirmed_mode_id = %normalized_mode,
840-
"acp_set_mode_confirmed"
841-
);
842-
Ok(())
843-
}
844-
845-
async fn apply_confirmed_model_selection(&self, model_id: &str) -> Result<SessionModelState, AgentError> {
846-
let session_id = {
847-
let session = self.session.read().await;
848-
if !session.can_select_model(model_id) {
849-
warn!(
850-
conversation_id = %self.params.conversation_id,
851-
agent_backend = ?self.params.metadata.backend,
852-
requested_model_id = %model_id,
853-
"acp_set_model_rejected_unavailable"
854-
);
855-
return Err(AgentError::bad_request(format!(
856-
"Model '{model_id}' is not available for this ACP session"
857-
)));
858-
}
859-
session.session_id().map(ToOwned::to_owned)
860-
}
861-
.ok_or_else(|| {
862-
warn!(
863-
conversation_id = %self.params.conversation_id,
864-
agent_backend = ?self.params.metadata.backend,
865-
requested_model_id = %model_id,
866-
"acp_set_command_missing_session"
867-
);
868-
AgentError::bad_request("No active session")
869-
})?;
870-
871-
info!(
872-
conversation_id = %self.params.conversation_id,
873-
agent_backend = ?self.params.metadata.backend,
874-
requested_model_id = %model_id,
875-
"acp_set_model_requested"
876-
);
877-
if let Err(e) = self
878-
.protocol
879-
.set_model(SetSessionModelRequest::new(
880-
SessionId::new(session_id.clone()),
881-
model_id.to_owned(),
882-
))
883-
.await
884-
{
885-
warn!(
886-
conversation_id = %self.params.conversation_id,
887-
agent_backend = ?self.params.metadata.backend,
888-
requested_model_id = %model_id,
889-
error = %e,
890-
"acp_set_model_failed"
891-
);
892-
return Err(AgentError::from(e));
893-
}
894-
895-
let mut session = self.session.write().await;
896-
if session.session_id() != Some(session_id.as_str()) {
897-
warn!(
898-
conversation_id = %self.params.conversation_id,
899-
agent_backend = ?self.params.metadata.backend,
900-
requested_model_id = %model_id,
901-
confirmed_session_id = %session_id,
902-
active_session_id = ?session.session_id(),
903-
"acp_set_model_session_changed"
904-
);
905-
return Err(AgentError::conflict("Active ACP session changed while applying model"));
906-
}
907-
session.confirm_model(ModelId::new(model_id));
908-
let confirmed_model = session
909-
.model_info()
910-
.cloned()
911-
.unwrap_or_else(|| SessionModelState::new(model_id.to_owned(), Vec::new()));
912-
self.commit_session_changes(&mut session).await;
913-
info!(
914-
conversation_id = %self.params.conversation_id,
915-
agent_backend = ?self.params.metadata.backend,
916-
confirmed_model_id = %model_id,
917-
"acp_set_model_confirmed"
918-
);
919-
Ok(confirmed_model)
920-
}
921-
922-
/// Set the model for the current session.
923-
pub(crate) async fn set_model(&self, model_id: &str) -> Result<(), AgentError> {
924-
self.apply_confirmed_model_selection(model_id).await?;
925-
Ok(())
926-
}
927-
928-
/// Set the model and return the confirmed model state from this write,
929-
/// without re-reading the asynchronously mutable session cache.
930-
pub(crate) async fn set_model_confirmed(&self, model_id: &str) -> Result<SessionModelState, AgentError> {
931-
self.apply_confirmed_model_selection(model_id).await
932-
}
933-
934798
/// Return available slash commands from the session aggregate.
935799
pub(crate) async fn load_slash_commands(&self) -> Result<Vec<SlashCommandItem>, AgentError> {
936800
let session = self.session.read().await;
@@ -990,6 +854,7 @@ impl AcpAgentManager {
990854
async fn ensure_session_opened(&self) -> Result<String, AgentError> {
991855
debug!("Ensuring ACP session is opened");
992856
let _lock = self.session_lock.lock().await;
857+
self.ensure_protocol_connected_for_operation("ensure_session_opened")?;
993858

994859
let (session_id, opened) = {
995860
let s = self.session.read().await;
@@ -1004,10 +869,16 @@ impl AcpAgentManager {
1004869

1005870
{
1006871
let mut s = self.session.write().await;
1007-
s.mark_opened();
872+
let sid = mark_session_opened_after_protocol_ready(
873+
&mut s,
874+
sid,
875+
self.protocol.is_connected(),
876+
&self.params.conversation_id,
877+
self.backend(),
878+
)?;
1008879
self.commit_session_changes(&mut s).await;
880+
Ok(sid)
1009881
}
1010-
Ok(sid)
1011882
}
1012883

1013884
/// Initialize or resume a session, then send the user message.
@@ -1312,8 +1183,8 @@ mod tests {
13121183
use crate::agent_runtime::AgentRuntime;
13131184
use crate::error::AgentError;
13141185
use crate::manager::acp::{AcpAgentManager, AcpSession};
1315-
use crate::protocol::error::CloseReason;
1316-
use crate::shared_kernel::{ConfigKey, ConfigValue};
1186+
use crate::protocol::error::{AcpError, CloseReason};
1187+
use crate::shared_kernel::{ConfigKey, ConfigValue, SessionId as DomainSessionId};
13171188
use agent_client_protocol::schema::{AvailableCommand, SessionConfigOptionCategory};
13181189
use serde_json::json;
13191190
use std::collections::HashMap;
@@ -1354,6 +1225,31 @@ mod tests {
13541225
assert_eq!(user_facing_message(&err), "Rate limited");
13551226
}
13561227

1228+
#[test]
1229+
fn warmup_does_not_mark_opened_when_protocol_disconnected_after_open() {
1230+
let mut session = AcpSession::new(None, None, Default::default());
1231+
session.set_session_id(DomainSessionId::new("sess-disconnected"));
1232+
1233+
let err = super::mark_session_opened_after_protocol_ready(
1234+
&mut session,
1235+
"sess-disconnected".to_owned(),
1236+
false,
1237+
"conv-test",
1238+
Some("codex"),
1239+
)
1240+
.expect_err("disconnected protocol must reject the opened transition");
1241+
1242+
assert!(
1243+
matches!(err, AgentError::Acp(AcpError::NotConnected)),
1244+
"expected AcpError::NotConnected, got {err:?}"
1245+
);
1246+
assert_eq!(session.session_id(), Some("sess-disconnected"));
1247+
assert!(
1248+
!session.is_opened(),
1249+
"warmup must not mark the aggregate opened when the protocol is already disconnected"
1250+
);
1251+
}
1252+
13571253
#[test]
13581254
fn nested_colons_only_strip_first() {
13591255
// "Bad gateway: Internal error: API Error: ..." → keep everything after the first ": "

0 commit comments

Comments
 (0)